diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 6a95043dd..bc52fd1e9 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -17,7 +17,7 @@
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
- "forwardPorts": [9078,3000],
+ "forwardPorts": [9078,3000,5433],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": ".devcontainer/postCreate.sh",
@@ -30,7 +30,9 @@
"dbaeumer.vscode-eslint",
"unifiedjs.vscode-mdx",
"bradlc.vscode-tailwindcss",
- "TakumiI.markdowntable"
+ "TakumiI.markdowntable",
+ "YoavBls.pretty-ts-errors",
+ "bowlerr.sqlite-intelliview-vscode"
]
}
},
@@ -40,6 +42,9 @@
},
"9078": {
"label": "App"
+ },
+ "5433": {
+ "label": "PGLite Server"
}
}
diff --git a/.gitignore b/.gitignore
index 2b5fa3b0d..e8a2071f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -122,6 +122,7 @@ config/*.json
config/mscache
config/yti-*
config/*.cache
+config/msDb
*.txt
!robots.txt
.idea/
@@ -138,6 +139,7 @@ docsite/static/schemas/*.json
*.bak
+*.bak.used
*.p8
.flatpak-builder
flatpak/generated-sources.json
@@ -151,7 +153,10 @@ tmp-*
*storybook.log
storybook-static
-lib
+
+*.db
+*.db.*
+*.db-*lib
*.db
*.db.*
*.db-*lib
\ No newline at end of file
diff --git a/.mocharc.json b/.mocharc.json
index c22a525b2..3d59041e9 100644
--- a/.mocharc.json
+++ b/.mocharc.json
@@ -6,5 +6,6 @@
"file": [
"./src/backend/tests/setup.ts"
],
- "exit": true
+ "exit": true,
+ "timeout": 2500
}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7094e312a..37b557082 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -13,5 +13,6 @@
"files.associations": {
"*.css": "tailwindcss"
},
- "tailwindCSS.experimental.configFile": "src/client/index.css"
+ "tailwindCSS.experimental.configFile": "src/client/index.css",
+ "pgliteExplorer.databasePaths": []
}
\ No newline at end of file
diff --git a/docsite/docs/configuration/configuration.mdx b/docsite/docs/configuration/configuration.mdx
index 13ce858ac..9ffb49417 100644
--- a/docsite/docs/configuration/configuration.mdx
+++ b/docsite/docs/configuration/configuration.mdx
@@ -245,11 +245,8 @@ WARN : [App] [Sources] [spotify Secrets] Matched: None | Unmatched: SPOTIFY_SECR
-## Application Options
-These options affect multi-scrobbler's behavior and are not specific to any source/client.
-
-### Base URL
+## Base URL
Defines the URL that is used to generate default redirect URLs for authentication on [spotify](/configuration/sources/spotify) and [lastfm](/configuration/clients/lastfm) -- as well as some logging hints.
@@ -280,185 +277,82 @@ Useful when running with [docker](../installation/installation.mdx#docker) so th
-### Caching
-
-Multi-scrobbler caches some activities to persist important data across restarts, reduce external API calls, and make some actions faster.
-
-All of the activities below are **always** cached **in-memory** with an optional, configurable [**secondary** store](#secondary-caching-configuration) for persistence.
-
-
-
-
-**Queued** and **Failed** Scrobbles are cached so that any un-scrobbled data you have is persisted across restarts of multi-scrobbler.
-
-:::tip
-
-By default, this data use a [Secondary](#secondary-caching-configuration) [File](./?cacheType=file#secondary-caching-configuration) store, configured for you automatically.
-
-If you have configured a [persisted volume/bind mount](/installation?dockerSetting=storage#recommended-settings) for configuration (`/config` is mounted in [docker compose](/quickstart#create-docker-compose-file)) then you are already done. If you are not persisting this directory then you should consider setting up [Valkey Cache](./?cacheType=valkey#secondary-caching-configuration) for this.
-
-:::
-
-##### Configuration
-
-Use any [Secondary Cache](#secondary-caching-configuration), the config examples below show the default values:
-
-
-
-
-| Environmental Variable | Required? | Default | Description |
-| :--------------------- | --------- | --------- | --------------------- |
-| `CACHE_SCROBBLE` | No | `file` | The cache type to use |
-| `CACHE_SCROBBLE_CONN` | No | `/config` | |
+## Caching
-
+Multi-scrobbler implements caching to persist important data across restarts, reduce external API calls, and make some actions faster.
-
+A default **in-memory** cache store is used so that you always benefit from some caching. An optional, [**secondary** store](#secondary-caching) can be configured for greater caching capabilities.
-```json5 title="config.json"
-{
- "cache": {
- "scrobble": {
- "provider": "file",
- "connection": "/config"
- }
- },
- // ...
-}
-```
-
-
+
-
+
-
Authentication sessions/tokens/etc... are cached for quicker requests and for persistence across restarts.
-
-:::tip
-
-By default, this data use a [Secondary](#secondary-caching-configuration) [File](./?cacheType=file#secondary-caching-configuration) store, configured for you automatically.
-
-If you have configured a [persisted volume/bind mount](/installation?dockerSetting=storage#recommended-settings) for configuration (`/config` is mounted in [docker compose](/quickstart#create-docker-compose-file)) then you are already done. If you are not persisting this directory then you should consider setting up [Valkey Cache](./?cacheType=valkey#secondary-caching-configuration) for this.
-
-:::
-
-##### Configuration
-
-Use any [Secondary Cache](#secondary-caching-configuration), the config examples below show the default values:
-
-
-
-
-| Environmental Variable | Required? | Default | Description |
-| :--------------------- | --------- | --------- | --------------------- |
-| `CACHE_AUTH` | No | `file` | The cache type to use |
-| `CACHE_AUTH_CONN` | No | `/config` | |
-
-
-
-
-```json5 title="config.json"
-{
- "cache": {
- "auth": {
- "provider": "file",
- "connection": "/config"
- }
- },
- // ...
-}
-```
-
-
-
+
+The results of [transform rules](/configuration/transforms) are cached so that if a scrobble with identical data (track/artists/album) is identified and it has the same set of transform rules then the cached transform results can be applied.
-
API Calls to external (metadata) services used to [Enhance Scrobbles](/configuration/transforms), like calls to [Musicbrainz](/configuration/transforms/musicbrainz), can be cached to avoid duplicate calls and speed up scrobble transformations.
-
-By default, these calls are only cached in memory. If you wish for cached calls to be persisted across restarts then setup [Valkey Cache](./?cacheType=valkey#secondary-caching-configuration).
-
-##### Configuration
-
-Use any [Secondary Cache](#secondary-caching-configuration), the config examples below show the default values:
-
-
-
-
-| Environmental Variable | Required? | Default | Description |
-| :--------------------- | --------- | ------- | --------------------- |
-| `CACHE_METADATA` | No | | The cache type to use |
-| `CACHE_METADATA_CONN` | No | | |
-
-
-
-
-
-```json5 title="config.json"
-{
- "cache": {
- "metadata": {
- "provider": "valkey",
- "connection": "yourConnectionStringHere"
- }
- },
- // ...
-}
-```
-
-
+
-#### Secondary Caching Configuration
+
-The type of cache used, and its connection properties, can be configured through **ENV** or **AIO** config.
+Auth caching defaults to a **file** that is stored in the `CONFIG_DIR` directory using the pre-defined file name `ms-auth.cache`.
-
+This provides automatic persistence across restarts for long-lived auth data/credentials if you have configured a [persisted volume/bind mount](/installation?dockerSetting=storage#recommended-settings) for configuration (`/config` is mounted in [docker compose](/quickstart#create-docker-compose-file)).
-
+If you wish to use the [secondary store](#secondary-caching) for caching Auth you must explicitly configure it. This is because valkey can potentially be *ephemeral* if you do not provide a volume for its data directory.
-**File** cache is stored in the `CONFIG_DIR` directory using the pre-defined file name `ms-[cacheName].cache`.
+To explicitly configure auth to use the secondary store:
-
-Example
-
-| Environmental Variable | Required? | Default | Description |
-| :--------------------- | --------- | --------- | ------------------------------------------------------------ |
-| `CACHE_SCROBBLE` | No | `file` | The cache type to use |
-| `CACHE_SCROBBLE_CONN` | No | `/config` | The directory, within the container, to store the cache file |
+```yaml title="compose.yaml"
+services:
+ multi-scrobbler:
+ # ...
+ environment:
+ // highlight-start
+ - CACHE_AUTH=valkey
+ // highlight-end
+ # ...
+```
-Example
-
```json5 title="config.json"
{
"cache": {
- "scrobble": {
- "provider": "file",
- "connection": "/config"
+ "auth": {
+ "provider": "valkey"
}
},
// ...
}
```
-
-
-
-
+
+
+### Secondary Caching Store {#secondary-caching}
+
+Using a secondary store enables:
-[**Valkey**](https://valkey.io/) is an open-source fork of Redis.
+* persistence of cached data across restarts
+* a larger store (more data is saved)
+* a longer time-to-live in the store (cached data is fetchable for a longer period)
+
+These benefits are particularly beneficial when using [transforms](/configuration/transforms) like Musicbrainz and it is **strongly recommended** for these scenarios.
+
+Currently, Multi-scrobbler only supports [**Valkey**](https://valkey.io/), an open-source fork of Redis, as a secondary store.
@@ -466,11 +360,13 @@ Example
A valkey container can be added to the [multi-scrobbler docker compose stack](/installation?runType=docker-compose#docker):
+
+
+
```yaml title="docker-compose.yml"
services:
multi-scrobbler:
# ...
- # adding everything below
// highlight-start
valkey:
image: valkey/valkey
@@ -483,6 +379,25 @@ volumes:
// highlight-end
```
+
+
+
+```yaml title="docker-compose.yml"
+services:
+ multi-scrobbler:
+ # ...
+ // highlight-start
+ valkey:
+ image: valkey/valkey
+ volumes:
+ - ./valkeyData:/data
+ // highlight-end
+```
+
+
+
+
+
Use `redis://valkey:6379` as the connection string in the configurations below.
@@ -497,12 +412,16 @@ redis://HOST_IP:HOST_PORT
-Example
-
-| Environmental Variable | Required? | Default | Description |
-| :--------------------- | --------- | -------- | ------------------------------------------------------------------- |
-| `CACHE_METADATA` | Yes | `valkey` | The cache type to use |
-| `CACHE_METADATA_CONN` | Yes | | The host/IP and port to connect to EX: `redis://192.168.0.120:6379` |
+```yaml title="compose.yaml"
+services:
+ multi-scrobbler:
+ # ...
+ environment:
+ // highlight-start
+ - CACHE_VALKEY=redis://192.168.0.120:6379
+ // highlight-end
+ # ...
+```
@@ -513,10 +432,7 @@ Example
```json5 title="config.json"
{
"cache": {
- "metadata": {
- "provider": "valkey",
- "connection": "redis://192.168.0.120:6379"
- }
+ "valkey": "redis://192.168.0.120:6379"
},
// ...
}
@@ -524,12 +440,9 @@ Example
-
-
-
-### Debug Mode
+## Debug Mode
Turning on Debug Mode will
@@ -550,7 +463,7 @@ To set debug mode either add it to [AIO `config.json`](./?configType=aio#configu
or set the [ENV](./?configType=env#configuration-types) `DEBUG_MODE=true`
-### Disable Web
+## Disable Web
If you do not need the dashboard and/or ingress sources, or have security concerns about ingress and cannot control your hosting environment, the web server and API can be disabled.
diff --git a/docsite/docs/configuration/sources/lastfm-endpoint.mdx b/docsite/docs/configuration/sources/lastfm-endpoint.mdx
index bf99c8705..6359d922b 100644
--- a/docsite/docs/configuration/sources/lastfm-endpoint.mdx
+++ b/docsite/docs/configuration/sources/lastfm-endpoint.mdx
@@ -41,6 +41,6 @@ http://localhost:9078/api/lastfm/mySlug
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | ------- | ------------------------------------------------------------------------------------------------------------------ |
- | `LFMENDPOINT_ENABLE` | No | | Use LFM Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
+ | `LFM_ENABLE` | No | | Use LFM Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
| `LFM_SLUG` | No | | (Optional) The URL suffix to use for accepting LFM scrobbles |
\ No newline at end of file
diff --git a/docsite/docs/configuration/sources/listenbrainz-endpoint.mdx b/docsite/docs/configuration/sources/listenbrainz-endpoint.mdx
index ce44dc3c4..976b84bde 100644
--- a/docsite/docs/configuration/sources/listenbrainz-endpoint.mdx
+++ b/docsite/docs/configuration/sources/listenbrainz-endpoint.mdx
@@ -111,7 +111,7 @@ To troubleshoot any errors, and assuming you are using Home Assistant, view the
| Environmental Variable | Required? | Default | Description |
| :--------------------- | :-------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
- | `LZENDPOINT_ENABLE` | No | | Use LZ Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
+ | `LZE_ENABLE` | No | | Use LZ Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
| `LZE_TOKEN` | No | | LZ "Authentication Token" you provided to the scrobbling application |
| `LZE_SLUG` | No | | (Optional) The URL suffix to use for accepting LZ scrobbles |
| `LZE_USERNAME` | No | | (Optional) A fake username that will be returned for token validation and on now-playing responses. If none is provided the Source's name is used instead. |
diff --git a/docsite/docs/configuration/transforms/transforms.mdx b/docsite/docs/configuration/transforms/transforms.mdx
index be1a3bcaf..b5780c691 100644
--- a/docsite/docs/configuration/transforms/transforms.mdx
+++ b/docsite/docs/configuration/transforms/transforms.mdx
@@ -591,7 +591,7 @@ The output shows the diff between the previous stage (or original Play) and the
MS uses [caching](/configuration/#caching) to reduce the number of API calls needed for stages like [Musicbrainz](/configuration/transforms/musicbrainz) and to speed up all transforms by caching steps and results. However, the default caching strategy uses a small cache size and very short [TTLs](https://en.wikipedia.org/wiki/Time_to_live) because it is *in-memory*.
-**If you are using any Transform stages you should configure [secondary caching with Valkey for Metadata](/configuration/?cacheType=valkey&cachedThings=metadata#secondary-caching-configuration)** to increase the cache size and lifetime of cached items. This will also reduce memory usage in MS.
+**If you are using any Transform stages you should configure [secondary caching](/configuration#secondary-caching)** to increase the cache size and lifetime of cached items. This will also reduce memory usage in MS.
diff --git a/docsite/docs/installation/installation.mdx b/docsite/docs/installation/installation.mdx
index 83f6f9bbd..93a292960 100644
--- a/docsite/docs/installation/installation.mdx
+++ b/docsite/docs/installation/installation.mdx
@@ -132,7 +132,7 @@ services:
-**Optionally**, add a [Valkey](https://valkey.io/) service to your stack for [secondary caching](/configuration/?cacheType=valkey#secondary-caching-configuration) to take advantage of faster performance and reduced memory usage.
+**Optionally**, add a [Valkey](https://valkey.io/) service to your stack for [secondary caching](/configuration#secondary-caching) to take advantage of faster performance and reduced memory usage.
```yaml title="docker-compose.yml"
services:
@@ -142,8 +142,7 @@ services:
environment:
# ...
// highlight-start
- CACHE_METADATA=valkey
- CACHE_METADATA_CONN=redis://valkey:6379
+ CACHE_VALKEY=redis://valkey:6379
// highlight-end
# ...
diff --git a/docsite/docs/quickstart.mdx b/docsite/docs/quickstart.mdx
index 140d106e5..f38abac6a 100644
--- a/docsite/docs/quickstart.mdx
+++ b/docsite/docs/quickstart.mdx
@@ -336,7 +336,7 @@ Visit `http://192.168.0.100:9078` to see the dashboard where
## Next Steps
* See more advanced docker options as well as other install methods in the [**Installation**](/installation#docker) docs
- * Setup [secondary caching](/configuration/?cacheType=valkey#secondary-caching-configuration) with [valkey](/installation?dockerSetting=caching#recommended-settings) for increased performance and reduced memory usage
+ * Setup [secondary caching](/configuration#secondary-caching) with [valkey](/installation?dockerSetting=caching#recommended-settings) for increased performance and reduced memory usage
* Review the [**Configuration**](/configuration) docs
* Learn about how to configure multi-scrobbler using files for more complicated Source/Client scenarios
* See all available [**Sources**](/configuration/sources) and [**Clients**](/configuration/clients) alongside configuration examples
diff --git a/docsite/docs/updating/updating.mdx b/docsite/docs/updating/updating.mdx
index 74aec3763..81b1563a9 100644
--- a/docsite/docs/updating/updating.mdx
+++ b/docsite/docs/updating/updating.mdx
@@ -10,11 +10,37 @@ import CodeBlock from '@theme/CodeBlock';
## Updating
-Currently, multi-scrobbler does not have any databases or dependencies that require additional interaction when updating.
+The majority of Multi-scrobbler updates can be completed without any manual intervention. This is **guaranteed** for [patch version updates](#versioning).
-Any **breaking changes** will be related to [configuration](/configuration) that has been deprecated/changed, or tooling that usually only affects [Local Installations.](/installation#local-installation)
+Regardless, it is recommended to consult the [**Github Release Notes**](https://github.com/FoxxMD/multi-scrobbler/releases) before any upgrades. The release notes contain all changelogs as well as most **breaking changes**/notices.
-These changes, and how to update configs accordingly, are detailed in [**Github Release Notes**](https://github.com/FoxxMD/multi-scrobbler/releases). It is recommended to check this page before upgrading [minor or major versions.](#versioning)
+The [**Upgrade Path** docs](/updating/upgrade-path) contain information for upgrading through **required versions** or in-depth migration guides. Check this section before upgrading any [minor versions](#versioning).
+
+### Database
+
+Multi-scrobbler depends on a SQLite database (`ms.db`) that is created on first run and stored in the [`CONFIG_DIR`](/installation/?dockerSetting=storage#recommended-settings).
+
+If any database migrations are required for an update then your database is **automatically backed up** before migration occurs. The backup file is created in the same directory.
+
+You can manually make a backup of this database by making a copy of `ms.db` and any similarly named files like `ms.db-journal`.
+
+:::tip
+
+If upgrading a [minor version](#versioning) you may want to make a backup for extra safety.
+
+:::
+
+### Configuration
+
+[Minor versions](#versioning) may have **breaking changes** related to [configuration](/configuration). Consult the [**Github Release Notes**](https://github.com/FoxxMD/multi-scrobbler/releases) and [Upgrade Paths](/updating/upgrade-path) before upgrading.
+
+
+## Update Instructions
+
+Assuming:
+
+* upgrade(s) are only [patch versions](#versioning) and the [Release Notes](https://github.com/FoxxMD/multi-scrobbler/releases) do not contain any other guidance or
+* you have checked the release notes, upgrade paths docs, and completed all migration steps for the specific version upgrade
diff --git a/docsite/docs/updating/upgrade-path/0140.mdx b/docsite/docs/updating/upgrade-path/0140.mdx
new file mode 100644
index 000000000..06c98dd89
--- /dev/null
+++ b/docsite/docs/updating/upgrade-path/0140.mdx
@@ -0,0 +1,212 @@
+---
+sidebar_position: 1
+title: From < 0.14.0
+description: Upgrading to 0.14.0
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import CodeBlock from '@theme/CodeBlock';
+import RequiredUpgrade from "@site/src/components/snippets/_upgrade-required.mdx"
+
+
+
+## Source/Client IDs
+
+MS `0.14.0` introduces a database for persisting Plays/Scrobbles, queues, and other data **associated with a Source/Client**.
+
+To make this association atomic a Source/Client **ID** is now configurable. This ID is what MS will use to identify the Source/Client in your **config** with the Source/Client in the **database**.
+
+This ID needs to be unique to the client or source type it is used on IE:
+
+* two [Koito Clients](/configuration/clients/koito/) cannot both have the ID `myKoitoID`
+* it is recommended that the ID be *globally* unique among all sources/clients, but it's not required
+
+### Configuring IDs
+
+
+
+
+
+For each Source/Client in your environmental variables, add a key with the suffix `_ID` and the ID value. EX
+
+```yaml title="compose.yaml"
+services:
+ multi-scrobbler:
+ # ...
+ environment:
+ - KOTIO_TOKEN=...
+ # ...more koito config
+ // highlight-start
+ - KOITO_ID=myKoitoID
+ // highlight-end
+
+ - JELLYFIN_URL=...
+ # ...more jellyin config
+ // highlight-start
+ - JELLYFIN_ID=myJellyfinID
+ // highlight-end
+```
+
+
+
+
+Add an `id` to the top-level for each Source/Client configuration, next to `data`:
+
+```json title="koito.json"
+[
+ {
+ "name": "koito-source",
+ "configureAs": "source",
+ // highlight-start
+ "id": "myKoitoID",
+ // highlight-end
+ "data": {
+ "token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
+ "username": "admin",
+ "url": "http://192.168.0.100:4110"
+ }
+ }
+]
+```
+
+
+
+
+Add an `id` to the top-level for each Source/Client configuration, next to `data`:
+
+```json title="koito.json"
+[
+ {
+ "name": "koito-source",
+ "configureAs": "source",
+ "type": "koito",
+ // highlight-start
+ "id": "myKoitoID",
+ // highlight-end
+ "data": {
+ "token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
+ "username": "admin",
+ "url": "http://192.168.0.100:4110"
+ }
+ }
+]
+```
+
+
+
+
+:::note[Default ID]
+
+If you do not add an ID then Multi-Scrobbler will automatically use the **name** of the Source/Client as the ID. The name is shown in the Dashboard.
+
+If you decide to add an ID later you must use the name as the ID in order to keep Plays/Scobbles associated with the same config.
+
+:::
+
+## Cached Scrobble Migration
+
+:::tip
+
+Before upgrading, if your MS dashboard shows 0 queued/failed for all [Scrobble Clients](/configuration/clients) then you **skip this step** and can safely delete `ms-scrobble.cache` before the upgrade occurs.
+
+:::
+
+MS `0.14.0` introduces a database for persisting Plays/Scrobbles, queues, and other data. Prior to `0.14.0`, queued/failed scrobbles were stored in a cache **file** inside your [`CONFIG_DIR`](/installation/?dockerSetting=storage#recommended-settings) named `ms-scrobble.cache`.
+
+On first run of `0.14.0`:
+
+* MS will make a copy of `ms-scrobble.cache` named `ms-scrobble.cache.bak`
+ * You may make a manual copy of this file before upgrading, for additional safety
+* Scrobbles in the cache will automatically be migrated to the new database
+* The now "old" cache data will be cleared so that subsequent application starts don't duplicate migrations
+ * the empty `ms-scrobble.cache` file will remain
+
+You can follow progress of this by looking for log lines starting with `Migrating cached scrobbles to database...`
+
+If MS does not report/log any errors during this time and you see your scrobbles processed normally then it is safe to delete `ms-scrobble.cache` the next time MS is stopped/restarted.
+
+## Cache Configuration
+
+In `0.14.0` [Cache](/configuration#caching) has been simplified with much of the required configuration being removed.
+
+:::tip
+
+This only applies to users who have `cache` in their [AIO Config](/configuration/?configType=aio#configuration-types) `config.json` or are using `CACHE_*` [ENV Config](/configuration/?configType=env#configuration-types)
+
+If you do not have any of the above [Cache](/configuration#caching) configuration defined then you can **skip this step.**
+
+:::
+
+#### Scrobble Caching Removed
+
+[Scrobble caching has removed and replaced by the new database.](#cached-scrobble-migration)
+
+* Remove any ENV Config starting with `CACHE_SCROBBLE`
+* Remove `cache.scrobble` from the AIO Config
+
+#### Metadata Config Simplified
+
+Metadata caching remains the same but the config has been simplified. If you were using [Valkey for caching](/configuration/#secondary-caching) update your config:
+
+
+
+
+
+* Remove `CACHE_METADATA`
+* Rename `CACHE_METADATA_CONN` to [`CACHE_VALKEY`](/configuration/#secondary-caching)
+
+
+
+
+Remove `cache.metadata` and add a new string key `valkey` to the `cache` object, containing your valkey connection string:
+
+```diff
+ "cache": {
++ "valkey": "redis://valkey:6379"
+- "metadata": {
+- "provider": "valkey",
+- "connection": "redis://valkey:6379"
+- }
+ }
+```
+
+
+
+
+
+#### Auth Config Simplified
+
+Auth caching remains the same but the config has been simplified. The connection option for Auth is no longer configurable. You may specify provider as either `file` (default, uses `CONFIG_DIR`) or `valkey`. If you want to use Valkey for auth it will use the same config as Metadata.
+
+
+
+
+
+* Remove `CACHE_AUTH_CONN`
+* Add [`CACHE_VALKEY`](/configuration/#secondary-caching) (if using valkey)
+
+
+
+
+Remove the `connection` property from the `auth` object. If using `file` the entire `auth` object can be removed.
+
+```diff
+ "cache": {
++ "valkey": "redis://valkey:6379",
+ "auth": {
+ "provider": "valkey",
+- "connection": "redis://valkey:6379"
+ }
+ }
+```
+
+
+
+
+### Renamed ENV Keys
+
+In an effort to standard ENV prefixes some ENV keys have been renamed:
+
+* `LZENDPOINT_ENABLE` => `LZE_ENABLE`
+* `LFMENDPOINT_ENABLE` => `LFM_ENABLE`
\ No newline at end of file
diff --git a/docsite/docs/updating/upgrade-path/_category_.json b/docsite/docs/updating/upgrade-path/_category_.json
new file mode 100644
index 000000000..ab29ac513
--- /dev/null
+++ b/docsite/docs/updating/upgrade-path/_category_.json
@@ -0,0 +1,7 @@
+{
+ "label": "Upgrade Path",
+ "link": {
+ "type": "doc",
+ "id": "updating/upgrade-path/upgrade-path"
+ }
+}
diff --git a/docsite/docs/updating/upgrade-path/upgrade-path.mdx b/docsite/docs/updating/upgrade-path/upgrade-path.mdx
new file mode 100644
index 000000000..8f3505b6e
--- /dev/null
+++ b/docsite/docs/updating/upgrade-path/upgrade-path.mdx
@@ -0,0 +1,39 @@
+---
+sidebar_position: 1
+title: Upgrade Path
+description: Updating Multi-Scrobbler
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+import CodeBlock from '@theme/CodeBlock';
+
+The majority of changes are detailed in [**Github Release Notes**](https://github.com/FoxxMD/multi-scrobbler/releases), including *most* breaking changes. You should consult the release notes for each version you will be updating through/to.
+
+Upgrade paths for specific version, detailed here, are reserved for:
+
+
+
+
+
+:::warning[Required Upgrade]
+
+Versions you **must** upgrade to **before** upgrading to a newer version. Likely due to the codebase containing functionality to upgrade persistent data that cannot be migrating across multiple versions.
+
+:::
+
+
+
+
+
+:::note[Optional Upgrade]
+
+(Breaking) Changes to configuration/data that require more detail or formatting than is feasible in Github release notes. These upgrades may be skipped.
+
+:::
+
+
+
+
+
+The type of upgrade is shown at the top of each page with the same colored callout shown above.
\ No newline at end of file
diff --git a/docsite/src/components/AdmonitionDetails.tsx b/docsite/src/components/AdmonitionDetails.tsx
index fa098547f..5f3352e97 100644
--- a/docsite/src/components/AdmonitionDetails.tsx
+++ b/docsite/src/components/AdmonitionDetails.tsx
@@ -5,9 +5,10 @@ import IconDanger from '@theme/Admonition/Icon/Danger';
import IconTip from '@theme/Admonition/Icon/Tip';
import IconNote from '@theme/Admonition/Icon/Note';
import { ReactElement } from 'react';
+import AdmonitionIconImportant from './ImportantIcon';
export interface DetailsAdmoProps extends DetailProps {
- type?: 'warning' | 'danger' | 'note' | 'tip'
+ type?: 'warning' | 'danger' | 'note' | 'tip' | 'important'
}
const DetailsAdmo = (props: DetailsAdmoProps) => {
@@ -32,6 +33,10 @@ const DetailsAdmo = (props: DetailsAdmoProps) => {
cn = 'alert--success';
icon =
break;
+ case 'important':
+ cn = 'alert--important';
+ icon = ;
+ break;
}
const iconWrapper = icon === undefined ? null : {icon};
diff --git a/docsite/src/components/ImportantIcon.tsx b/docsite/src/components/ImportantIcon.tsx
new file mode 100644
index 000000000..b37786787
--- /dev/null
+++ b/docsite/src/components/ImportantIcon.tsx
@@ -0,0 +1,13 @@
+import type { Props } from '@theme/Admonition/Icon/Info';
+
+export default function AdmonitionIconImportant(props: Props) {
+ // based on https://icon-sets.iconify.design/zondicons/exclamation-outline/
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/docsite/src/components/snippets/_upgrade-optional.mdx b/docsite/src/components/snippets/_upgrade-optional.mdx
new file mode 100644
index 000000000..e69de29bb
diff --git a/docsite/src/components/snippets/_upgrade-required.mdx b/docsite/src/components/snippets/_upgrade-required.mdx
new file mode 100644
index 000000000..657519f96
--- /dev/null
+++ b/docsite/src/components/snippets/_upgrade-required.mdx
@@ -0,0 +1,15 @@
+:::warning[Required Upgrade]
+
+You **MUST** upgrade to this version **before** upgrading Multi-Scrobbler to a newer version.
+
+This version contains functionality to migrate your data from **{props.before}** that newer versions depend on.
+
+
+
+Upgrade Process Example
+
+* Upgrade Multi-Scrobbler {props.old} to {props.current}
+* **After** upgrading, you can optionally upgrade to > {props.current}
+
+
+:::
\ No newline at end of file
diff --git a/docsite/src/css/custom.css b/docsite/src/css/custom.css
index 25603721f..46485615b 100644
--- a/docsite/src/css/custom.css
+++ b/docsite/src/css/custom.css
@@ -13,6 +13,7 @@
--ifm-color-primary-light: #33925d;
--ifm-color-primary-lighter: #359962;
--ifm-color-primary-lightest: #3cad6e;
+ --ifm-color-important-contrast-background: #bb91ff;
--ifm-code-font-size: 95%;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
}
@@ -26,6 +27,7 @@
--ifm-color-primary-light: #29d5b0;
--ifm-color-primary-lighter: #32d8b4;
--ifm-color-primary-lightest: #4fddbf;
+ --ifm-color-important-contrast-background: #5a3795;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
}
@@ -59,6 +61,10 @@ details[class^='details_'].alert--info {
--ifm-alert-background-color: var(--ifm-color-secondary-contrast-background);
--ifm-alert-foreground-color: var(--ifm-color-secondary-contrast-foreground);
}
+ &.alert--important {
+ --ifm-alert-background-color: var(--ifm-color-important-contrast-background);
+ --ifm-alert-foreground-color: var(--ifm-color-secondary-contrast-foreground);
+ }
> summary {
--docusaurus-details-decoration-color: currentColor;
diff --git a/drizzle.config.ts b/drizzle.config.ts
new file mode 100644
index 000000000..8f3a7112f
--- /dev/null
+++ b/drizzle.config.ts
@@ -0,0 +1,13 @@
+import 'dotenv/config';
+import { defineConfig } from 'drizzle-kit';
+import { configDir, projectDir } from './src/backend/common/index.js';
+import * as path from 'path';
+
+export default defineConfig({
+ schema: path.resolve(projectDir, 'src/backend/common/database/drizzle/schema'),
+ out: path.resolve(projectDir, 'src/backend/common/database/drizzle/migrations'),
+ dialect: 'postgresql',
+ dbCredentials: {
+ url: path.resolve(configDir, 'msDb'),
+ },
+});
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 28d54273d..82d80b471 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,8 @@
"@atproto/api": "^0.18.0",
"@atproto/oauth-client-node": "^0.3.10",
"@donedeal0/superdiff": "^1.1.1",
+ "@electric-sql/pglite": "^0.4.5",
+ "@electric-sql/pglite-socket": "^0.1.5",
"@ewanc26/tid": "^1.0.2",
"@foxxmd/chromecast-client": "^1.0.4",
"@foxxmd/get-version": "^0.0.3",
@@ -47,6 +49,7 @@
"dbus-ts": "^0.0.7",
"discord.js": "^14.26.0",
"dotenv": "^10.0.0",
+ "drizzle-orm": "^1.0.0-rc.2-c5a84d1",
"express": "^5.2.1",
"express-session": "^1.19.0",
"fast-equals": "^6.0.0",
@@ -71,6 +74,7 @@
"mpc-js": "^2.1.1",
"musicbrainz-api": "^0.27.0",
"nanoid": "^3.3.1",
+ "neotraverse": "^0.6.18",
"node-object-hash": "^3.1.1",
"normalize-url": "^8.0.1",
"ntfy": "^1.15.2",
@@ -102,6 +106,7 @@
"@chromatic-com/storybook": "^5.0.1",
"@curvenote/ansi-to-react": "^7.0.0",
"@dbus-types/notifications": "^0.0.5",
+ "@electric-sql/pglite-prepopulatedfs": "^0.0.3",
"@emotion/react": "^11.14.0",
"@eslint/js": "^8.56.0",
"@faker-js/faker": "^9.0.1",
@@ -129,7 +134,7 @@
"@types/jest": "^27.5.2",
"@types/jscodeshift": "^0.11.6",
"@types/mocha": "^9.1.0",
- "@types/node": "^20.19.2",
+ "@types/node": "^24.12.2",
"@types/object-hash": "^3.0.0",
"@types/passport": "^1.0.12",
"@types/react": "^18.2.18",
@@ -144,6 +149,7 @@
"chai": "^4.3.6",
"chai-as-promised": "^8.0.2",
"clsx": "^2.1.1",
+ "drizzle-kit": "^1.0.0-rc.2-c5a84d1",
"eslint": "^8.56.0",
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
"eslint-plugin-storybook": "10.1.11",
@@ -152,7 +158,6 @@
"mocha": "^10.3.0",
"mockdate": "^3.0.5",
"msw": "^2.12.10",
- "neotraverse": "^0.6.18",
"next-themes": "^0.4.6",
"nodemon": "^3.0.3",
"playwright": "^1.58.2",
@@ -168,11 +173,12 @@
"sinon": "^21.0.2",
"storybook": "10.2.0",
"tailwindcss": "^4.2.2",
- "ts-essentials": "^10.1.1",
+ "tinyexec": "^1.1.1",
+ "ts-essentials": "^10.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^7.0.1",
"vite": "^5.4.21",
- "with-local-tmp-dir": "^6.0.0"
+ "with-local-tmp-dir": "^7.0.1"
},
"engines": {
"node": ">=24.14.0",
@@ -1055,6 +1061,13 @@
"version": "1.1.3",
"license": "ISC"
},
+ "node_modules/@drizzle-team/brocli": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.11.0.tgz",
+ "integrity": "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/@dword-design/chdir": {
"version": "4.0.0",
"dev": true,
@@ -1066,6 +1079,31 @@
"url": "https://github.com/sponsors/dword-design"
}
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.5.tgz",
+ "integrity": "sha512-aGG2zGEyZzGWKy8P+9ZoNUV0jxt1+hgbeTf+bVAYyxVZZLXg3/9aFlfLxb08AYZVAfAkQlQIysmWjhc5hwDG8g==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@electric-sql/pglite-prepopulatedfs": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-prepopulatedfs/-/pglite-prepopulatedfs-0.0.3.tgz",
+ "integrity": "sha512-3MNFt+gR0P22foWi55j/HZ6DvQ82DEVIvmoKVYCdoG/gezMikGR794tO07/15CV4RcR3PnfCPoQjApPfXfD01w==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@electric-sql/pglite-socket": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.5.tgz",
+ "integrity": "sha512-/RAye+3EPKfO9nY4tljzxXmkT7yIpFDm0L3F+c28b+Z6uxPOjy/Zz/QEHYHXcrfuUC88/a9S72EO0+3E0j97wQ==",
+ "license": "Apache-2.0",
+ "bin": {
+ "pglite-server": "dist/scripts/server.js"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "0.4.5"
+ }
+ },
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"dev": true,
@@ -2174,6 +2212,19 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@js-temporal/polyfill": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz",
+ "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "jsbi": "^4.3.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/@kenyip/backoff-strategies": {
"version": "1.0.4",
"license": "MIT"
@@ -4288,10 +4339,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "20.19.37",
+ "version": "24.12.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
+ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"license": "MIT",
"dependencies": {
- "undici-types": "~6.21.0"
+ "undici-types": "~7.16.0"
}
},
"node_modules/@types/object-hash": {
@@ -4364,11 +4417,6 @@
"@types/node": "*"
}
},
- "node_modules/@types/retry": {
- "version": "0.12.2",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@types/send": {
"version": "0.17.4",
"dev": true,
@@ -7241,6 +7289,665 @@
"node": ">=10"
}
},
+ "node_modules/drizzle-kit": {
+ "version": "1.0.0-rc.2-c5a84d1",
+ "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-1.0.0-rc.2-c5a84d1.tgz",
+ "integrity": "sha512-TjXBd6/Jo8aqC2uBbbgPoIVE5Qr9s0KubVtdj3Ro7bF2lz1TkGOhnDIzsOgMJPMSPn9uyhNG6WBKdGz+XzcL9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@drizzle-team/brocli": "^0.11.0",
+ "@js-temporal/polyfill": "^0.5.1",
+ "esbuild": "^0.25.10",
+ "get-tsconfig": "^4.13.6",
+ "jiti": "^2.6.1"
+ },
+ "bin": {
+ "drizzle-kit": "bin.cjs"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/drizzle-kit/node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/drizzle-orm": {
+ "version": "1.0.0-rc.2-c5a84d1",
+ "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-1.0.0-rc.2-c5a84d1.tgz",
+ "integrity": "sha512-2nw0eVFNcLRU7mhs/f/UodwvRIP78ZzZXMrl/r6LpTpcJ42ZzQZZTH0ObPCR722SaEzn35kT4gvuoiam/OCK9g==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@aws-sdk/client-rds-data": ">=3",
+ "@cloudflare/workers-types": ">=4",
+ "@effect/sql-pg": ">=4.0.0-beta.58 || >=4.0.0",
+ "@electric-sql/pglite": ">=0.2.0",
+ "@libsql/client": ">=0.10.0",
+ "@libsql/client-wasm": ">=0.10.0",
+ "@neondatabase/serverless": ">=0.10.0",
+ "@op-engineering/op-sqlite": ">=2",
+ "@opentelemetry/api": "^1.4.1",
+ "@planetscale/database": ">=1.13",
+ "@sinclair/typebox": ">=0.34.8",
+ "@sqlitecloud/drivers": ">=1.0.653",
+ "@tidbcloud/serverless": "*",
+ "@tursodatabase/database": ">=0.2.1",
+ "@tursodatabase/database-common": ">=0.2.1",
+ "@tursodatabase/database-wasm": ">=0.2.1",
+ "@types/better-sqlite3": "*",
+ "@types/mssql": "^9.1.4",
+ "@types/pg": "*",
+ "@types/sql.js": "*",
+ "@upstash/redis": ">=1.34.7",
+ "@vercel/postgres": ">=0.8.0",
+ "@xata.io/client": "*",
+ "arktype": ">=2.0.0",
+ "better-sqlite3": ">=9.3.0",
+ "bun-types": "*",
+ "effect": ">=4.0.0-beta.58 || >=4.0.0",
+ "expo-sqlite": ">=14.0.0",
+ "mssql": "^11.0.1",
+ "mysql2": ">=2",
+ "pg": ">=8",
+ "postgres": ">=3",
+ "sql.js": ">=1",
+ "sqlite3": ">=5",
+ "typebox": ">=1.0.0",
+ "valibot": ">=1.0.0-beta.7",
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@aws-sdk/client-rds-data": {
+ "optional": true
+ },
+ "@cloudflare/workers-types": {
+ "optional": true
+ },
+ "@effect/sql-pg": {
+ "optional": true
+ },
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "@libsql/client": {
+ "optional": true
+ },
+ "@libsql/client-wasm": {
+ "optional": true
+ },
+ "@neondatabase/serverless": {
+ "optional": true
+ },
+ "@op-engineering/op-sqlite": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@planetscale/database": {
+ "optional": true
+ },
+ "@sinclair/typebox": {
+ "optional": true
+ },
+ "@sqlitecloud/drivers": {
+ "optional": true
+ },
+ "@tidbcloud/serverless": {
+ "optional": true
+ },
+ "@tursodatabase/database": {
+ "optional": true
+ },
+ "@tursodatabase/database-common": {
+ "optional": true
+ },
+ "@tursodatabase/database-wasm": {
+ "optional": true
+ },
+ "@types/better-sqlite3": {
+ "optional": true
+ },
+ "@types/mssql": {
+ "optional": true
+ },
+ "@types/pg": {
+ "optional": true
+ },
+ "@types/sql.js": {
+ "optional": true
+ },
+ "@upstash/redis": {
+ "optional": true
+ },
+ "@vercel/postgres": {
+ "optional": true
+ },
+ "@xata.io/client": {
+ "optional": true
+ },
+ "arktype": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "bun-types": {
+ "optional": true
+ },
+ "effect": {
+ "optional": true
+ },
+ "expo-sqlite": {
+ "optional": true
+ },
+ "mssql": {
+ "optional": true
+ },
+ "mysql2": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ },
+ "postgres": {
+ "optional": true
+ },
+ "sql.js": {
+ "optional": true
+ },
+ "sqlite3": {
+ "optional": true
+ },
+ "typebox": {
+ "optional": true
+ },
+ "valibot": {
+ "optional": true
+ },
+ "zod": {
+ "optional": true
+ }
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"license": "MIT",
@@ -8564,7 +9271,9 @@
}
},
"node_modules/get-tsconfig": {
- "version": "4.8.1",
+ "version": "4.14.0",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
+ "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
@@ -9597,6 +10306,13 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsbi": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz",
+ "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"dev": true,
@@ -10924,7 +11640,8 @@
},
"node_modules/neotraverse": {
"version": "0.6.18",
- "dev": true,
+ "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz",
+ "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==",
"license": "MIT",
"engines": {
"node": ">= 10"
@@ -13430,6 +14147,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tinyexec": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
+ "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"dev": true,
@@ -13594,7 +14321,9 @@
}
},
"node_modules/ts-essentials": {
- "version": "10.1.1",
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.2.0.tgz",
+ "integrity": "sha512-z9FlLywg0XEV46Ws1FwYN4NZDMr9qAe38lTTtgVBqzhhyEgwrnCUkFe4MEqnvar1kY1kFEnlkp56bxn2g0V+UA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -13947,7 +14676,9 @@
}
},
"node_modules/undici-types": {
- "version": "6.21.0",
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/unicorn-magic": {
@@ -14723,37 +15454,23 @@
}
},
"node_modules/with-local-tmp-dir": {
- "version": "6.0.0",
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/with-local-tmp-dir/-/with-local-tmp-dir-7.0.1.tgz",
+ "integrity": "sha512-ipyC5Q9ZBbpeLnrBi4BEUElnvDxv0QM0I+ptSIGUMegR9/KVz/bd+WMMWm0Q0qnjOSwSRaxqVfqzy0kVPmaVig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dword-design/chdir": "^4.0.0",
"@types/fs-extra": "^11.0.4",
- "p-retry": "^6.2.1"
+ "p-retry": "^7.1.1"
},
"engines": {
- "node": ">=20"
+ "node": ">=22"
},
"funding": {
"url": "https://github.com/sponsors/dword-design"
}
},
- "node_modules/with-local-tmp-dir/node_modules/p-retry": {
- "version": "6.2.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/retry": "0.12.2",
- "is-network-error": "^1.0.0",
- "retry": "^0.13.1"
- },
- "engines": {
- "node": ">=16.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/word-wrap": {
"version": "1.2.5",
"dev": true,
@@ -15089,7 +15806,9 @@
}
},
"node_modules/zod": {
- "version": "3.23.8",
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -15097,6 +15816,8 @@
},
"node_modules/zod-validation-error": {
"version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-2.1.0.tgz",
+ "integrity": "sha512-VJh93e2wb4c3tWtGgTa0OF/dTt/zoPCPzXq4V11ZjxmEAFaPi/Zss1xIZdEB5RD8GD00U0/iVXgqkF77RV7pdQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
diff --git a/package.json b/package.json
index 02066d3f8..9951bd2cc 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"build:backend": "tsc -p src/backend && npm run -s schema:app",
"build": "npm run -s build:backend && npm run -s build:frontend && npm run -s docs:build",
"build:parallel": "concurrently --kill-others-on-fail --names backend,frontend,docs \"npm run -s build:backend\" \"npm run -s build:frontend\" \"npm run docs:build\"",
+ "db:start": "pglite-server --db=./config/msDb --port=5433 --host=0.0.0.0 -m 10",
"docs:install": "cd docsite && npm install --no-audit",
"docs:start": "cd docsite && npm start",
"docs:build": "npm run -s schema:docs && cd docsite && npm run build",
@@ -53,6 +54,8 @@
"@atproto/api": "^0.18.0",
"@atproto/oauth-client-node": "^0.3.10",
"@donedeal0/superdiff": "^1.1.1",
+ "@electric-sql/pglite": "^0.4.5",
+ "@electric-sql/pglite-socket": "^0.1.5",
"@ewanc26/tid": "^1.0.2",
"@foxxmd/chromecast-client": "^1.0.4",
"@foxxmd/get-version": "^0.0.3",
@@ -85,6 +88,7 @@
"dbus-ts": "^0.0.7",
"discord.js": "^14.26.0",
"dotenv": "^10.0.0",
+ "drizzle-orm": "^1.0.0-rc.2-c5a84d1",
"express": "^5.2.1",
"express-session": "^1.19.0",
"fast-equals": "^6.0.0",
@@ -109,6 +113,7 @@
"mpc-js": "^2.1.1",
"musicbrainz-api": "^0.27.0",
"nanoid": "^3.3.1",
+ "neotraverse": "^0.6.18",
"node-object-hash": "^3.1.1",
"normalize-url": "^8.0.1",
"ntfy": "^1.15.2",
@@ -140,6 +145,7 @@
"@chromatic-com/storybook": "^5.0.1",
"@curvenote/ansi-to-react": "^7.0.0",
"@dbus-types/notifications": "^0.0.5",
+ "@electric-sql/pglite-prepopulatedfs": "^0.0.3",
"@emotion/react": "^11.14.0",
"@eslint/js": "^8.56.0",
"@faker-js/faker": "^9.0.1",
@@ -167,7 +173,7 @@
"@types/jest": "^27.5.2",
"@types/jscodeshift": "^0.11.6",
"@types/mocha": "^9.1.0",
- "@types/node": "^20.19.2",
+ "@types/node": "^24.12.2",
"@types/object-hash": "^3.0.0",
"@types/passport": "^1.0.12",
"@types/react": "^18.2.18",
@@ -182,6 +188,7 @@
"chai": "^4.3.6",
"chai-as-promised": "^8.0.2",
"clsx": "^2.1.1",
+ "drizzle-kit": "^1.0.0-rc.2-c5a84d1",
"eslint": "^8.56.0",
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
"eslint-plugin-storybook": "10.1.11",
@@ -190,7 +197,6 @@
"mocha": "^10.3.0",
"mockdate": "^3.0.5",
"msw": "^2.12.10",
- "neotraverse": "^0.6.18",
"next-themes": "^0.4.6",
"nodemon": "^3.0.3",
"playwright": "^1.58.2",
@@ -206,11 +212,12 @@
"sinon": "^21.0.2",
"storybook": "10.2.0",
"tailwindcss": "^4.2.2",
- "ts-essentials": "^10.1.1",
+ "tinyexec": "^1.1.1",
+ "ts-essentials": "^10.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^7.0.1",
"vite": "^5.4.21",
- "with-local-tmp-dir": "^6.0.0"
+ "with-local-tmp-dir": "^7.0.1"
},
"browserslist": {
"production": [
diff --git a/src/backend/common/AbstractComponent.ts b/src/backend/common/AbstractComponent.ts
index bab2f1229..514bd6221 100644
--- a/src/backend/common/AbstractComponent.ts
+++ b/src/backend/common/AbstractComponent.ts
@@ -6,7 +6,7 @@ import { LifecycleStep, PlayData, PlayObject, TransformResult } from "../../core
import { buildPlayHumanDiffable, buildTrackString } from "../../core/StringUtils.js";
import { CommonClientConfig } from "./infrastructure/config/client/index.js";
import { CommonSourceConfig } from "./infrastructure/config/source/index.js";
-import { mergeSimpleError, SkipTransformStageError, StagePrerequisiteError, StageTransformError, TransformRulesError } from "./errors/MSErrors.js";
+import { mergeSimpleError, SimpleError, SkipTransformStageError, StagePrerequisiteError, StageTransformError, TransformRulesError } from "./errors/MSErrors.js";
import {
FLOW_CONTROL_TERM,
PlayTransformRules,
@@ -20,13 +20,21 @@ import { getRoot } from "../ioc.js";
import { nanoid } from "nanoid";
import { isDebugMode } from "../utils.js";
import { findCauseByReference } from "../utils/ErrorUtils.js";
-import { hashObject } from "../utils/StringUtils.js";
+import { hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.js";
import { metaInvariantTransform, playContentInvariantTransform } from "../utils/PlayComparisonUtils.js";
import { MSCache } from "./Cache.js";
import { diffObjects, diffObjectsConsoleOutput, patchObject } from "../../core/DataUtils.js";
import clone from "clone";
import { loggerNoop } from "./MaybeLogger.js";
import { objectsEqual } from "../utils/DataUtils.js";
+import { RetentionOptions } from "./infrastructure/config/database.js";
+import { getRetentionCompactAfterFromEnv, getRetentionDeleteAfterFromEnv, isCompactableProperty, parseRetentionOptions, parseRetentionOptionsDurations } from "./database/Database.js";
+import { DbConcrete } from "./database/drizzle/drizzleUtils.js";
+import { ComponentSelect } from "./database/drizzle/drizzleTypes.js";
+import { DrizzlePlayRepository } from "./database/drizzle/repositories/PlayRepository.js";
+import { ClientType } from "./infrastructure/config/client/clients.js";
+import { SourceType } from "./infrastructure/config/source/sources.js";
+import { DrizzleComponentRepository } from "./database/drizzle/repositories/ComponentRepository.js";
export type AbstractComponentConfig = (CommonClientConfig | CommonSourceConfig) & { transformManager?: TransformerManager };
@@ -38,11 +46,27 @@ export default abstract class AbstractComponent extends AbstractInitializable {
regexCache!: ReturnType;
protected transformManager: TransformerManager;
protected cache: MSCache;
+ protected db: DbConcrete;
+ protected componentRepo!: DrizzleComponentRepository;
+ protected dbComponent!: ComponentSelect;
+ protected retentionOpts: RetentionOptions;
+
+ protected componentType: 'source' | 'client';
+ type: ClientType | SourceType;
protected constructor(config: AbstractComponentConfig) {
super(config);
this.transformManager = config.transformManager ?? getRoot().items.transformerManager;
this.cache = getRoot().items.cache();
+ const cProps = config.options?.retention?.compact ?? parseArrayFromMaybeString(process.env.COMPACT_PROPERTIES, {lower: true});
+ if(!cProps.every(isCompactableProperty)) {
+ throw new SimpleError(`Compactable properties must be one of 'transform' or 'input'. Given: ${cProps.join(',')}`);
+ }
+ this.retentionOpts = {
+ deleteAfter: parseRetentionOptionsDurations(config.options?.retention?.deleteAfter, getRetentionDeleteAfterFromEnv()),
+ compactAfter: parseRetentionOptions(config.options?.retention?.compactAfter, getRetentionCompactAfterFromEnv()),
+ compact: cProps
+ };
}
protected postCache(): Promise {
@@ -54,6 +78,25 @@ export default abstract class AbstractComponent extends AbstractInitializable {
}
}
+ protected async doBuildDatabase(): Promise {
+ super.doBuildDatabase();
+
+ let name: string;
+ if('name' in this) {
+ name = this.name as string;
+ }
+
+ this.db = await getRoot().items.db();
+ this.componentRepo = new DrizzleComponentRepository(this.db, {logger: this.logger});
+ this.dbComponent = await this.componentRepo.findOrInsert({
+ mode: this.componentType,
+ type: this.type,
+ uid: this.config.id ?? this.config.name ?? name,
+ name: this.config.name ?? name
+ });
+ return true;
+ }
+
public buildTransformRules() {
this.logger.debug('Building transformer rules...');
try {
@@ -154,6 +197,19 @@ export default abstract class AbstractComponent extends AbstractInitializable {
}
}
+ public retentionCleanup = async () => {
+ if(this.databaseOK !== true) {
+ this.logger.warn(`Cannot run retention cleanup because ${this.componentType} database state is not OK`);
+ return;
+ }
+ try {
+ const repo = new DrizzlePlayRepository(this.db, {logger: this.logger});
+ await repo.retentionCleanup(this.componentType, this.retentionOpts);
+ } catch (e) {
+ this.logger.warn(new Error('Failed to do retention cleanup', {cause: e}));
+ }
+ }
+
protected transformPartToStrong(data: any) {
if(data === undefined) {
return undefined;
diff --git a/src/backend/common/AbstractInitializable.ts b/src/backend/common/AbstractInitializable.ts
index 43c688424..56afa7dcf 100644
--- a/src/backend/common/AbstractInitializable.ts
+++ b/src/backend/common/AbstractInitializable.ts
@@ -13,6 +13,7 @@ export default abstract class AbstractInitializable {
authFailure?: boolean;
buildOK?: boolean | null;
+ databaseOK?: boolean | null;
connectionOK?: boolean | null;
cacheOK?: boolean | null;
@@ -41,6 +42,7 @@ export default abstract class AbstractInitializable {
if(this.componentLogger === undefined) {
await this.buildComponentLogger();
}
+ await this.buildDatabase(force);
await this.buildInitData(force);
await this.parseCache(force);
try {
@@ -96,13 +98,13 @@ export default abstract class AbstractInitializable {
if(!force) {
return;
}
- this.logger.debug('Cache OK but step was forced');
+ this.logger.verbose('Cache OK but step was forced');
}
try {
const res = await this.doParseCache();
if(res === undefined) {
this.cacheOK = null;
- this.logger.debug('No cache to parse.');
+ this.logger.trace('No cache to parse.');
return;
}
if (res === true) {
@@ -139,13 +141,13 @@ export default abstract class AbstractInitializable {
if(!force) {
return;
}
- this.logger.debug('Build OK but step was forced');
+ this.logger.verbose('Build OK but step was forced');
}
try {
const res = await this.doBuildInitData();
if(res === undefined) {
this.buildOK = null;
- this.logger.debug('No required data to build.');
+ this.logger.trace('No required data to build.');
return;
}
if (res === true) {
@@ -172,6 +174,58 @@ export default abstract class AbstractInitializable {
return;
}
+ public async buildDatabase(force: boolean = false) {
+ if(this.databaseOK) {
+ if(!force) {
+ return;
+ }
+ this.logger.verbose('Database OK but step was forced');
+ }
+ try {
+ const res = await this.doBuildDatabase();
+ if(res === undefined) {
+ this.databaseOK = null;
+ this.logger.trace('No required database steps.');
+ return;
+ }
+ if (res === true) {
+ this.logger.verbose('Required database init succeeded');
+ } else if (typeof res === 'string') {
+ this.logger.verbose(`Required database init succeeded => ${res}`);
+ }
+ this.databaseOK = true;
+ } catch (e) {
+ this.databaseOK = false;
+ throw new BuildDataError('Required database init failed', {cause: e});
+ }
+
+ try {
+ await this.postDatabase();
+ } catch (e) {
+ if(e instanceof StageError) {
+ throw e;
+ } else {
+ throw new Error('Error occurred during post-database hook', {cause: e});
+ }
+ }
+ }
+
+ protected async postDatabase(): Promise {
+ return;
+ }
+
+ /**
+ * Run/fetch/create any database data needed for this component to operate when ready
+ *
+ * * Return undefined if not possible or not required
+ * * Return TRUE if database steps succeeded
+ * * Return string if database steps succeeded and should log result
+ * * Throw error on failure
+ * */
+ protected async doBuildDatabase(): Promise {
+ return;
+ }
+
public async checkConnection(force: boolean = false) {
if(this.connectionOK) {
if(!force) {
@@ -252,12 +306,14 @@ export default abstract class AbstractInitializable {
public isReady() {
return (this.buildOK === null || this.buildOK === true) &&
+ (this.databaseOK === null || this.databaseOK === true) &&
(this.connectionOK === null || this.connectionOK === true)
&& !this.authGated();
}
public isUsable() {
return (this.buildOK === null || this.buildOK === true) &&
+ (this.databaseOK === null || this.databaseOK === true) &&
(this.connectionOK === null || this.connectionOK === true);
}
diff --git a/src/backend/common/Cache.ts b/src/backend/common/Cache.ts
index 8483489c2..a4d7a8151 100644
--- a/src/backend/common/Cache.ts
+++ b/src/backend/common/Cache.ts
@@ -13,14 +13,15 @@ import { childLogger, Logger } from '@foxxmd/logging';
import { projectDir } from './index.js';
import path from 'path';
import { cacheFunctions } from "@foxxmd/regex-buddy-core";
-import { fileOrDirectoryIsWriteable } from '../utils/FSUtils.js';
-import { asCacheAuthProvider, asCacheConfig, asCacheMetadataProvider, asCacheScrobbleProvider, CacheAuthProvider, CacheConfig, CacheConfigOptions, CacheMetadataProvider, CacheProvider, CacheScrobbleProvider } from './infrastructure/Atomic.js';
+import { fileExists, fileOrDirectoryIsWriteable } from '../utils/FSUtils.js';
+import { copyFile } from 'fs/promises';
+import { asCacheConfig, CacheAuthProvider, CacheConfig, CacheConfigOptions, CacheConfigUser, CacheScrobbleProvider } from './infrastructure/Atomic.js';
import { Typeson } from 'typeson';
import { builtin } from 'typeson-registry';
import { loggerNoop } from './MaybeLogger.js';
-import { ListenProgressPositional, ListenProgressTS } from '../sources/PlayerState/ListenProgress.js';
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
import prom, { Gauge } from 'prom-client';
+import { nonEmptyStringOrDefault } from '../../core/StringUtils.js';
dayjs.extend(utc)
dayjs.extend(isBetween);
@@ -37,17 +38,24 @@ typeson.register({
(x) => dayjs.isDayjs(x),
(d: Dayjs) => d.toJSON(),
(date) => dayjs(date)
- ],
- ListenProgressTS,
- ListenProgressPositional
+ ]
});
+const unsupportedEnvKeys = [
+ 'CACHE_AUTH_CONN',
+ 'CACHE_METADATA',
+ 'CACHE_SCROBBLE',
+ 'CACHE_SCROBBLE_CONN',
+ 'CACHE_AUTH_CONN'
+];
+
export class MSCache {
config: Required
cacheMetadata: Cacheable;
cacheScrobble: Cacheable;
+ cacheDb: Cacheable;
cacheAuth: Cacheable;
regexCache: ReturnType;
cacheTransform: Cacheable;
@@ -68,19 +76,19 @@ export class MSCache {
const {
metadata: {
- provider: mProvider = (process.env.CACHE_METADATA as (CacheMetadataProvider | undefined) ?? false),
- connection: mConn = process.env.CACHE_METADATA_CONN,
- ...restMetadata
+ provider: mProvider = false,
+ connection: mConn,
+ //...restMetadata
} = {},
scrobble: {
- provider: sProvider = (process.env.CACHE_SCROBBLE as (CacheScrobbleProvider | undefined) ?? 'file'),
- connection = (process.env.CACHE_SCROBBLE_CONN ?? configDir),
- ...restScrobble
+ provider: sProvider = false,
+ connection: sConnection,
+ //...restScrobble
} = {},
auth: {
- provider: aProvider = (process.env.CACHE_AUTH as (CacheAuthProvider | undefined) ?? 'file'),
- connection: aConn = (process.env.CACHE_AUTH_CONN ?? configDir),
- ...restAuth
+ provider: aProvider = false,
+ connection: aConn,
+ //...restAuth
} = {},
regex = 200,
} = config;
@@ -89,17 +97,14 @@ export class MSCache {
metadata: {
provider: mProvider,
connection: mConn,
- ...restMetadata,
},
scrobble: {
provider: sProvider,
- connection,
- ...restScrobble
+ connection: sConnection,
},
auth: {
provider: aProvider,
connection: aConn,
- ...restAuth
},
regex
};
@@ -114,6 +119,7 @@ export class MSCache {
this.cacheAuth = inMemory;
this.cacheScrobble = inMemory;
this.cacheApi = inMemory;
+ this.cacheDb = new Cacheable({primary: initMemoryCache({lruSize: 500, ttl: '1m'})});
}
init = async (enableCollectors: boolean = false) => {
@@ -133,7 +139,8 @@ export class MSCache {
{ cache: this.cacheScrobble, name: 'queued_scrobbles' },
{ cache: this.cacheTransform, name: 'transformer' },
{ cache: this.cacheClientScrobbles, name: 'historical_scrobbles' },
- { cache: this.cacheApi, name: 'external_apis' }
+ { cache: this.cacheApi, name: 'external_apis' },
+ { cache: this.cacheDb, name: 'database' }
];
this.cacheHits = new prom.Gauge({
@@ -271,10 +278,10 @@ export class MSCache {
}
}
if (config.provider === 'file') {
- logger.debug(`Building file cache from ${path.join(config.connection, `${namespace}.cache`)}`);
+ logger.debug(`Building file cache from ${path.join(config.connection ?? configDir, `${namespace}.cache`)}`);
try {
- const [keyvFile] = await initFileCache({ ...config, cacheDir: config.connection, cacheId: `${namespace}.cache` }, {ttl: config.ttl}, logger);
+ const [keyvFile] = await initFileCache({ ...config, cacheDir: config.connection ?? configDir, cacheId: `${namespace}.cache` }, {ttl: config.ttl}, logger);
return keyvFile;
} catch (e) {
throw e;
@@ -364,8 +371,8 @@ export const flatCacheCreate = (opts: FlatCacheOptions) => {
return new FlatCache({
ttl: 0,
lruSize: 2000,
- cacheDir: opts.cacheDir ?? configDir,
- cacheId: opts.cacheId ?? 'scrobble.cache',
+ cacheDir: opts.cacheDir,
+ cacheId: opts.cacheId ?? 'ms.cache',
persistInterval: 1 * 1000 * 10,
expirationInterval: 1 * 1000 * 10, // 10 seconds
...opts
@@ -381,6 +388,12 @@ export const flatCacheLoad = async (flatCache: FlatCache, logger: Logger = logge
throw new Error(`Unable to use path for file cache at ${cachePath}`, { cause: e })
}
+ if(fileExists(cachePath) && !fileExists(`${cachePath}.bak`)) {
+ logger.info(`Backing up ${cachePath} in preparation for migration to database...`);
+ await copyFile(cachePath, `${cachePath}.bak`);
+ logger.info(`Done! Backed up to ${cachePath}.bak\nAll data has been loaded into cache. It will be deleted (from cache) after migrating to database.\nIf there are migration issues or you wish to downgrade then overwrite ${cachePath} with the .bak backup copy`);
+ }
+
const streamPromise = new Promise((resolve, reject) => {
flatCache.loadFileStream(cachePath, (progress: number, total: number) => {
logger.trace(`Loading ${progress}/${total} chunks...`);
@@ -497,4 +510,86 @@ const noopKeyv: KeyvStoreAdapter = {
delete: (_) => undefined,
clear: () => Promise.resolve(),
on: (_, __) => undefined
+}
+
+export const parseUserConfig = (config: CacheConfigUser = {}, parentLogger: Logger = loggerNoop): CacheConfigOptions => {
+ const logger = childLogger(parentLogger, 'Cache');
+
+ let valkeyEnvVal: string | undefined = nonEmptyStringOrDefault(process.env.CACHE_VALKEY);
+ if(valkeyEnvVal === undefined) {
+ valkeyEnvVal = nonEmptyStringOrDefault(process.env.CACHE_METADATA_CONN);
+ if(valkeyEnvVal !== undefined) {
+ logger.warn('ENV CACHE_METADATA_CONN is deprecated! Replace it with CACHE_VALKEY');
+ }
+ }
+
+ for(const key of unsupportedEnvKeys) {
+ if(nonEmptyStringOrDefault(process.env[key]) !== undefined) {
+ logger.warn(`ENV ${key} is no longer supported. Refer to the Caching docs.`);
+ }
+ }
+
+ const {
+ valkey = valkeyEnvVal,
+ // metadata: {
+ // provider: mProvider = (process.env.CACHE_METADATA as (CacheMetadataProvider | undefined) ?? false),
+ // connection: mConn = process.env.CACHE_METADATA_CONN,
+ // //...restMetadata
+ // } = {},
+ scrobble: {
+ provider: sProvider = (process.env.CACHE_SCROBBLE as (CacheScrobbleProvider | undefined) ?? 'file'),
+ connection = (process.env.CACHE_SCROBBLE_CONN ?? configDir),
+ ...restScrobble
+ } = {},
+ auth: {
+ provider: aProvider = (process.env.CACHE_AUTH as (CacheAuthProvider | undefined) ?? 'file'),
+ //...restAuth
+ } = {},
+ regex = 200,
+ } = config;
+
+ if(config.metadata !== undefined) {
+ logger.warn('Configuring cache.metadata is no longer supported. Refer to the Caching docs.');
+ }
+ if(config.scrobble !== undefined) {
+ logger.warn('Configuring cache.scrobble is no longer supported. Refer to the Caching docs.');
+ }
+ if(config.auth?.connection !== undefined) {
+ logger.warn('Configuring cache.auth.connection is no longer supported. Refer to the Caching docs.');
+ }
+
+ let authConn: string,
+ authProvider = aProvider;
+ if(authProvider === 'valkey') {
+ if(valkey === undefined) {
+ logger.warn(`Auth Provider set to 'valkey' but not valkey connection string was not provided, falling back to file.`);
+ authConn = configDir;
+ authProvider = 'file';
+ } else {
+ authConn = valkey;
+ }
+ } else {
+ if(authProvider !== 'file') {
+ logger.warn(`Unsupported provider given for auth: ${authProvider}`);
+ }
+ authConn = configDir;
+ authProvider = 'file';
+ }
+
+ return {
+ metadata: {
+ provider: valkey !== undefined ? 'valkey' : false,
+ connection: valkey,
+ },
+ scrobble: {
+ provider: sProvider,
+ connection,
+ ...restScrobble
+ },
+ auth: {
+ provider: authProvider,
+ connection: authConn,
+ },
+ regex
+ };
}
\ No newline at end of file
diff --git a/src/backend/common/database/Database.ts b/src/backend/common/database/Database.ts
new file mode 100644
index 000000000..6a64373f3
--- /dev/null
+++ b/src/backend/common/database/Database.ts
@@ -0,0 +1,192 @@
+import { configDir } from '../index.js';
+import * as path from 'path';
+import { promises as fs } from 'fs'
+import { childLogger, Logger } from '@foxxmd/logging';
+import { loggerNoop } from '../MaybeLogger.js';
+import { fileExists, fileOrDirectoryIsWriteable } from '../../utils/FSUtils.js';
+import { COMPACTABLE, compactableProperties, CompactableProperty, DEFAULT_RETENTION_DELETE_AFTER, RententionGranular, RetentionConfig, RetentionConfigValue, RetentionOption, RetentionValue, RetentionValueUnparsed } from '../infrastructure/config/database.js';
+import { DurationValue } from '../infrastructure/Atomic.js';
+import { Duration } from 'dayjs/plugin/duration.js';
+import dayjs from 'dayjs';
+import { parseDurationFromDurationValue } from '../../utils/TimeUtils.js';
+import assert, { AssertionError } from 'node:assert';
+import { parseBoolStrict } from '../../utils.js';
+import { SimpleError } from '../errors/MSErrors.js';
+
+export const MEMORY_DB_NAME = ':memory:';
+export const isMemoryDb = (name: string): boolean => name === MEMORY_DB_NAME;
+
+export const getDbPath = (name: string = 'msDb', workingDirectory?: string): string => {
+ if (isMemoryDb(name)) {
+ return MEMORY_DB_NAME;
+ }
+ return path.resolve(workingDirectory ?? configDir, `${name}`);
+}
+
+export const getDbBackupPath = (dbPath: string, suffix?: string): string => {
+ const pathInfo = path.parse(dbPath);
+ const backupPath = `${path.join(pathInfo.dir, pathInfo.name)}.bak${suffix !== undefined ? `.${suffix}` : ''}`;
+ return backupPath;
+}
+
+export const backupDb = async (dbName: string, opts: { logger?: Logger, workingDirectory?: string } = {}): Promise => {
+
+ const {
+ logger: parentLogger = loggerNoop,
+ workingDirectory
+ } = opts;
+
+ const logger = childLogger(parentLogger, 'Migrations');
+
+ const dbPath = getDbPath(dbName, workingDirectory);
+ let newDb = false;
+
+ if (dbPath !== MEMORY_DB_NAME) {
+ if (!fileExists(dbPath)) {
+ logger.info(`Database at ${dbPath} does not exist, will create it.`);
+ newDb = true;
+ }
+ try {
+ fileOrDirectoryIsWriteable(dbPath);
+ } catch (e) {
+ throw new Error('Database path/folder is not writeable, cannot backup database', { cause: e });
+ }
+ }
+
+ if (dbPath !== MEMORY_DB_NAME && !newDb) {
+ const backupPath = `${getDbPath(`${Date.now()}-${dbName}`, workingDirectory)}.bak`;
+ logger.info(`Backing up database before migrating => ${backupPath}`);
+ await fs.copyFile(dbPath, backupPath)
+ logger.info('Backed up!');
+ }
+}
+
+const parseRetentionValue = (val: RetentionValueUnparsed): RetentionValue => {
+ if(typeof val === 'string' || typeof val === 'boolean') {
+ try {
+ const boolVal = parseBoolStrict(val);
+ assert(boolVal === false, 'retention value cannot be true');
+ return boolVal;
+ } catch (e) {
+ // swallow
+ }
+ } else if(dayjs.isDuration(val)) {
+ return val;
+ } else {
+ return parseDurationFromDurationValue(val);
+ }
+ throw new SimpleError('retention value be of one: false, number, or string');
+}
+
+const parseRetentionFromEnv = (): RetentionOption => {
+ const deleteAfterEnv = process.env.RETENTION_DELETE_AFTER ?? DEFAULT_RETENTION_DELETE_AFTER,
+ deleteCompletedEnv = process.env.RETENTION_DELETE_COMPLETED_AFTER ?? deleteAfterEnv,
+ deleteFailedEnv = process.env.RETENTION_DELETE_FAILED_AFTER ?? deleteAfterEnv,
+ deleteDupedEnv = process.env.RETENTION_DELETE_DUPED_AFTER ?? deleteAfterEnv;
+
+ return {
+ completed: parseRetentionValue(deleteCompletedEnv),
+ failed: parseRetentionValue(deleteFailedEnv),
+ duped: parseRetentionValue(deleteDupedEnv)
+ }
+}
+
+const isRetentionOptionDurations = (val: RetentionOption): val is RetentionOption => {
+ return dayjs.isDuration(val.completed)
+ && dayjs.isDuration(val.duped)
+ && dayjs.isDuration(val.failed);
+}
+
+let retentionDeleteAfterFromEnv: RetentionOption,
+retentionCompactAfterFromEnv: RetentionOption;
+
+export const getRetentionDeleteAfterFromEnv = () => {
+ if (retentionDeleteAfterFromEnv === undefined) {
+ const deleteEnv = parseRetentionFromEnv();
+ if(isRetentionOptionDurations(deleteEnv)) {
+ retentionDeleteAfterFromEnv = deleteEnv;
+ } else {
+ throw new SimpleError('retention deleteAfter values from env must all be one of: number or string');
+ }
+ }
+ return retentionDeleteAfterFromEnv;
+}
+export const getRetentionCompactAfterFromEnv = () => {
+ if (retentionCompactAfterFromEnv === undefined) {
+ const compactEnv = parseRetentionFromEnv();
+ retentionCompactAfterFromEnv = compactEnv;
+ }
+ return retentionCompactAfterFromEnv;
+}
+
+export const parseRetentionOptions = (opts: RetentionConfigValue = {}, defaults: RetentionOption): RetentionOption => {
+ if (typeof opts === 'number' || typeof opts === 'string') {
+ const dur = parseDurationFromDurationValue(opts);
+ return {
+ completed: dur,
+ duped: dur,
+ failed: dur
+ }
+ }
+
+ if (opts === undefined) {
+ return defaults;
+ }
+
+ if(dayjs.isDuration(opts)) {
+ return {
+ completed: opts,
+ failed: opts,
+ duped: opts
+ }
+ }
+
+ const {
+ completed = defaults.completed,
+ failed = defaults.failed,
+ duped = defaults.duped
+ } = opts;
+
+ return {
+ completed: parseRetentionValue(completed),
+ failed: parseRetentionValue(failed),
+ duped: parseRetentionValue(duped),
+ }
+}
+
+export const parseRetentionOptionsDurations = (opts: RetentionConfigValue> = {}, defaults: RetentionOption): RetentionOption => {
+ if (typeof opts === 'number' || typeof opts === 'string') {
+ const dur = parseDurationFromDurationValue(opts);
+ return {
+ completed: dur,
+ duped: dur,
+ failed: dur
+ }
+ }
+
+ if(dayjs.isDuration(opts)) {
+ return {
+ completed: opts,
+ failed: opts,
+ duped: opts
+ }
+ }
+
+ if (opts === undefined) {
+ return defaults;
+ }
+
+ const {
+ completed = defaults.completed,
+ failed = defaults.failed,
+ duped = defaults.duped
+ } = opts;
+
+ return {
+ completed: dayjs.isDuration(completed) ? completed : parseDurationFromDurationValue(completed),
+ failed: dayjs.isDuration(failed) ? failed : parseDurationFromDurationValue(failed),
+ duped: dayjs.isDuration(duped) ? duped : parseDurationFromDurationValue(duped),
+ }
+}
+
+export const isCompactableProperty = (val: string): val is CompactableProperty => val === COMPACTABLE.input || val === COMPACTABLE.transform;
\ No newline at end of file
diff --git a/src/backend/common/database/drizzle/drizzleTypes.ts b/src/backend/common/database/drizzle/drizzleTypes.ts
new file mode 100644
index 000000000..654c70120
--- /dev/null
+++ b/src/backend/common/database/drizzle/drizzleTypes.ts
@@ -0,0 +1,100 @@
+import { DBQueryConfig, DBQueryConfigWith, ExtractTablesFromSchema, KnownKeysOnly, RelationFieldsFilterInternals, Many, InferSelectModel, ExtractTablesWithRelations, type BuildQueryResult, RelationsFilter } from "drizzle-orm";
+import { components, playInputs, plays, queueStates, relations } from "./schema/schema.js";
+import {TSchema, TableName, Schema } from "./schema/schema.js";
+import { MarkOptional, MarkRequired } from "ts-essentials";
+
+
+export type ComponentNew = typeof components.$inferInsert;
+export type ComponentSelect = typeof components.$inferSelect;
+
+export type QueueStateNew = typeof queueStates.$inferInsert;
+export type QueueStateSelect = typeof queueStates.$inferSelect;
+
+export type PlayInputNew = typeof playInputs.$inferInsert;
+export type PlayInputSelect = typeof playInputs.$inferSelect;
+
+export type PlaySelect = typeof plays.$inferSelect;
+export type PlaySelectRel = ModelWithRelations;
+export type PlaySelectWithQueueStates = GenericRelationResult<'plays', 'queueStates'>;
+export type PlayNew = typeof plays.$inferInsert;
+
+
+// useful references for building types
+// https://github.com/drizzle-team/drizzle-orm/discussions/2596
+// https://github.com/drizzle-team/drizzle-orm/discussions/1539
+// https://gist.github.com/ikupenov/10bc89d92d92eaba8cc5569013e04069
+// https://github.com/drizzle-team/drizzle-orm/issues/695 most examples
+// https://github.com/drizzle-team/drizzle-orm/discussions/2316 relation focused
+// https://github.com/drizzle-team/drizzle-orm/issues/1319
+
+//type p = TSchema['plays']['relations'];
+export type FindWith = DBQueryConfigWith;
+export type QueryConfig = DBQueryConfig<"many", TSchema, TSchema[T]>;
+export type FindMany = Pick, DBQueryConfig<"many", TSchema, TSchema[T]>>, 'where' | 'orderBy' | 'limit' | 'offset' | 'extras'> & {with?: FindWith}
+export type FindOne = Pick, DBQueryConfig<"one", TSchema, TSchema[T]>>, 'where' | 'orderBy' | 'limit' | 'offset' | 'extras'> & {with?: FindWith}
+export type FindWhere = QueryConfig['where'];
+// https://github.com/drizzle-team/drizzle-orm/issues/5218#issuecomment-4154686086
+export type WhereClause = RelationsFilter
+
+
+export type CompareOp = Pick, 'gt' | 'gte' | 'eq' | 'lt' | 'lte' | 'ne'>
+export type CompareOpKey = keyof CompareOp;
+
+
+
+/**
+ * Based on https://github.com/drizzle-team/drizzle-orm/issues/695#issuecomment-3133969178
+ */
+
+// Helper type to find the tsName corresponding to a given dbName in TSchema
+type FindTsNameByDbName = {
+ [K in keyof TSchema]: TSchema[K] extends {
+ // updated dbName -> name
+ name: TDbNameToFind;
+ }
+ ? K
+ : TDbNameToFind;
+}[keyof TSchema];
+
+// Helper type to find the dbName corresponding to a given tsName in TSchema
+type FindDbNameByTsName = {
+ [K in keyof Schema]: Schema[K] extends TTable ? K : never;
+}[keyof Schema];
+
+/**
+ * Utility type to infer the model type for a given table name from the schema.
+ * Handles nested relations recursively.
+ * Uses referencedTableName (dbName) and FindTsNameByDbName helper.
+ */
+export type ModelWithRelationsFromName<
+ TTableName extends keyof TSchema,
+> = InferSelectModel & {
+ [K in keyof TSchema[TTableName]['relations']]?: TSchema[TTableName]['relations'][K] extends infer TRelation
+ // updated referencedTableName -> targetTableName
+ ? TRelation extends { targetTableName: infer TRefDbName extends string }
+ ? FindTsNameByDbName extends infer TRefTsName extends
+ keyof TSchema
+ ? TRelation extends Many
+ ? ModelWithRelationsFromName[]
+ : ModelWithRelationsFromName | null
+ : never
+ : never
+ : never;
+};
+
+/**
+ * Utility type to infer the model type for a given table from the schema.
+ * Handles nested relations recursively.
+ * Uses referencedTableName (dbName) and FindDbNameByTsName helper.
+ */
+export type ModelWithRelations =
+ FindDbNameByTsName extends infer TTableName extends keyof TSchema
+ ? ModelWithRelationsFromName
+ : never;
+
+
+// all relations are are now fully typed and optional
+//type FullPlay = ModelWithRelations;
+
+// https://github.com/drizzle-team/drizzle-orm/issues/695#issuecomment-4389296482
+type GenericRelationResult = BuildQueryResult }>;
\ No newline at end of file
diff --git a/src/backend/common/database/drizzle/drizzleUtils.ts b/src/backend/common/database/drizzle/drizzleUtils.ts
new file mode 100644
index 000000000..25f0bbbf2
--- /dev/null
+++ b/src/backend/common/database/drizzle/drizzleUtils.ts
@@ -0,0 +1,263 @@
+import { drizzle } from 'drizzle-orm/node-sqlite';
+import { drizzle as drizzlePglite } from 'drizzle-orm/pglite';
+import { migrate } from 'drizzle-orm/node-sqlite/migrator';
+import { migrate as migratePglite } from 'drizzle-orm/pglite/migrator';
+import { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
+import { PGlite, PGliteOptions } from '@electric-sql/pglite';
+import { sql as dsl, LogWriter, Logger as DrizzleLogger } from 'drizzle-orm';
+import * as fs from 'fs/promises';
+import * as fsSync from 'fs';
+import * as path from 'path';
+import { backupDb, getDbBackupPath, getDbPath, MEMORY_DB_NAME } from '../Database.js';
+import { fileExists, fileOrDirectoryIsWriteable } from '../../../utils/FSUtils.js';
+import { childLogger, Logger, LogLevel } from '@foxxmd/logging';
+import { loggerNoop } from '../../MaybeLogger.js';
+import { projectDir } from '../../index.js';
+import { relations } from './schema/schema.js';
+import { addToContext, executeQuery } from './logContext.js';
+
+export async function shouldBackupDb(dbVal: string | DbConcrete, opts: {logger?: Logger, migrationsFolder?: string} = {}): Promise<[boolean, string[]]> {
+ const {
+ logger: parentLogger = loggerNoop,
+ migrationsFolder = path.resolve(projectDir, 'src/backend/common/database/drizzle/migrations')
+ } = opts;
+ const logger = childLogger(parentLogger, 'Migrations');
+
+ let db: DbConcrete;
+
+ if(typeof dbVal === 'string') {
+ logger.info(`Checking for database at ${dbVal}`);
+ if (dbVal !== MEMORY_DB_NAME && !fileExists(dbVal)) {
+ logger.info(`No database exists, no backup needed.`);
+ return [false, []];
+ }
+ db = await getDb(dbVal);
+ } else {
+ db = dbVal;
+ }
+
+
+ // const db = drizzlePglite(dbPath);
+
+ try {
+ // Ensure the migrations table exists
+ // https://github.com/drizzle-team/drizzle-orm/issues/1953
+ const res = await db.execute(dsl`
+ SELECT EXISTS (
+ SELECT FROM
+ pg_tables
+ WHERE
+ schemaname = 'drizzle' AND
+ tablename = '__drizzle_migrations'
+ );
+ `);
+
+ // const res3 = await db.execute(dsl`
+ // SELECT * FROM
+ // pg_tables;
+ // `);
+
+ if (res.rows[0].exists === false) {
+ logger.info(`Database exists but there is no __drizzle_migrations table??`);
+ return [true, []];
+ }
+
+ const dbMigrations = await db.execute(dsl`SELECT id, hash, created_at, name, applied_at FROM drizzle.__drizzle_migrations ORDER BY created_at DESC`);
+ // @ts-ignore
+ const appliedMigrations = new Set(dbMigrations.rows.map((m: any) => m.name));
+
+ const allFiles = await fs.readdir(migrationsFolder);
+ const migrationFiles = allFiles
+ .sort();
+
+ const pendingMigrations = migrationFiles.filter(file => {
+ return !appliedMigrations.has(file);
+ });
+
+ //console.log('Applied migrations:', Array.from(appliedMigrations));
+ if (pendingMigrations.length > 0) {
+ logger.info(`${pendingMigrations.length} pending migrations:\n${pendingMigrations.join('\n')}`);
+ return [true, pendingMigrations];
+ } else {
+ logger.info('No pending migrations.');
+ return [false, []];
+ }
+ } catch (error) {
+ logger.error(new Error('Failed to get pending migrations', { cause: error }));
+ return [true, []];
+ }
+}
+
+export const getDb = async (dbVal: string | PGlite, opts: { logger?: Logger, backupPath?: string, loadDataDir?: Promise } = {}) => {
+ const {
+ logger = loggerNoop,
+ backupPath,
+ loadDataDir
+ } = opts;
+ let client: PGlite;
+
+ if(typeof dbVal === 'string') {
+ const opts: PGliteOptions = {};
+ if(dbVal !== MEMORY_DB_NAME) {
+ opts.dataDir = dbVal;
+ if(backupPath !== undefined) {
+ opts.loadDataDir = new Blob([fsSync.readFileSync(backupPath)]);
+ }
+ }
+ // only load one
+ // but this could be for a memory db so don't put it in above if
+ if(loadDataDir !== undefined && backupDb === undefined) {
+ opts.loadDataDir = await loadDataDir
+ }
+ client = await PGlite.create(opts);
+ } else {
+ client = dbVal;
+ }
+ return drizzlePglite({relations: relations, logger: createDrizzleLogger(logger), client});
+}
+
+export type DbConcrete = Awaited>;
+
+export const migrateDb = async (db: DbConcrete, opts: {logger?: Logger, migrationsFolder?: string} = {}) => {
+ const {
+ migrationsFolder,
+ logger: parentLogger = loggerNoop
+ } = opts;
+ const logger = childLogger(parentLogger, 'Migrations');
+
+ try {
+ logger.info('Starting migrations...');
+ await executeQuery('migrations', async () => migratePglite(db, { migrationsFolder: migrationsFolder ?? path.resolve(projectDir, 'src/backend/common/database/drizzle/migrations') }), logger, process.env.LOG_MIGRATION === 'true' ? true : 'error');
+ logger.info('Migrations complete');
+ } catch (e) {
+ throw new Error('Failed to migrate database', { cause: e });
+ }
+}
+
+export const migrateDbSync = (db: ReturnType, opts: {logger?: Logger, migrationsFolder?: string} = {}) => {
+ const {
+ migrationsFolder,
+ logger: parentLogger = loggerNoop
+ } = opts;
+ const logger = childLogger(parentLogger, 'Migrations');
+
+ try {
+ logger.info('Starting migrations...');
+ migrate(db, { migrationsFolder: migrationsFolder ?? path.resolve(projectDir, 'src/backend/common/database/drizzle/migrations') });
+ logger.info('Migrations complete');
+ } catch (e) {
+ throw new Error('Failed to migrate database', { cause: e });
+ }
+}
+
+export const getMigratedDb = async (dbPath: string, opts: { logger?: Logger, workingDirectory?: string, migrationsFolder?: string, backupPath?: string, loadDataDir?: Promise } = {}): Promise<[DbConcrete, boolean]> => {
+ const {
+ logger = loggerNoop
+ } = opts;
+ let db: DbConcrete,
+ isNew = false,
+ hasPendingMigrations: boolean = true;
+ if (dbPath !== MEMORY_DB_NAME) {
+ try {
+ fileOrDirectoryIsWriteable(dbPath);
+ } catch (e) {
+ throw new Error('Database directory is not accessible', { cause: e });
+ }
+
+ const backupPath = getDbBackupPath(dbPath);
+
+ if (fileExists(dbPath)) {
+ db = await getDb(dbPath, opts);
+ const [shouldBackup, pendingMigrations] = await shouldBackupDb(db, opts);
+ if (shouldBackup) {
+ hasPendingMigrations = true;
+ await backupPgDb(db, dbPath, { logger: opts.logger });
+ }
+ } else if(fileExists(backupPath)) {
+ logger.info(`Detected no database, using backup to recreate db. Backup file: ${backupPath}`);
+ db = await getDb(dbPath, {...opts, backupPath});
+ const usedBackedPath = getDbBackupPath(dbPath, 'used');
+ logger.info(`Backup loaded! Renaming backup to indicate it has already been used, new path: ${usedBackedPath}`);
+ await fs.rename(backupPath, usedBackedPath);
+ } else {
+ logger.info('Detected no database, creating a new one...');
+ db = await getDb(dbPath, opts);
+ isNew = true;
+ }
+ } else {
+ logger.info('Detected in-memory database');
+ db = await getDb(dbPath, opts);
+ isNew = true;
+ }
+
+ if(hasPendingMigrations && dbPath !== MEMORY_DB_NAME) {
+ logger.info('TIP: Migrations may take some time, depending on the size of your database');
+ }
+ await migrateDb(db, opts);
+
+ return [db, isNew];
+}
+
+export const backupPgDb = async (db: DbConcrete, dbPath: string, opts: { logger?: Logger } = {}): Promise => {
+
+ const {
+ logger: parentLogger = loggerNoop,
+ } = opts;
+
+ const logger = childLogger(parentLogger, 'Backup');
+
+ const pathInfo = path.parse(dbPath);
+ // being extra sure there isn't a trailing slash
+ const backupPath = `${path.join(pathInfo.dir, pathInfo.name)}-${Date.now()}.bak`;
+ logger.info(`Backing up database before migrating => ${backupPath}`);
+ fs.writeFile(backupPath, Buffer.from(await (await db.$client.dumpDataDir()).arrayBuffer()));
+ //await fs.copyFile(dbPath, backupPath)
+ logger.info('Backed up!');
+}
+
+export const createDrizzleLogger = (parentLogger: Logger, opts: {level?: LogLevel} = {}): DrizzleLogger => {
+ return {
+ logQuery: (query: string, params: unknown[]) => {
+ addToContext({sql: query, params})
+ }
+ }
+}
+
+
+// cannot really use transactions right now because async isn't supporting for sqlite
+// https://github.com/drizzle-team/drizzle-orm/issues/1472
+// https://github.com/drizzle-team/drizzle-orm/issues/2275
+// so use this workaround for now
+// https://github.com/drizzle-team/drizzle-orm/issues/2275#issuecomment-2496503801
+let currentTransaction: null | Promise = null;
+export const runTransaction = async <
+ T,
+ TQueryResult,
+ TSchema extends Record = Record
+>(
+ db: BaseSQLiteDatabase<"sync", TQueryResult, TSchema>,
+ executor: () => Promise
+) => {
+ while (currentTransaction !== null) {
+ await currentTransaction;
+ }
+ let resolve!: () => void;
+ currentTransaction = new Promise(_resolve => {
+ resolve = _resolve;
+ });
+ try {
+ db.run(dsl.raw(`BEGIN`))
+
+ try {
+ const result = await executor();
+ await db.run(dsl.raw(`COMMIT`));
+ return result;
+ } catch (error) {
+ await db.run(dsl.raw(`ROLLBACK`));
+ throw error;
+ }
+ } finally {
+ resolve();
+ currentTransaction = null;
+ }
+};
\ No newline at end of file
diff --git a/src/backend/common/database/drizzle/entityUtils.ts b/src/backend/common/database/drizzle/entityUtils.ts
new file mode 100644
index 000000000..5c208922b
--- /dev/null
+++ b/src/backend/common/database/drizzle/entityUtils.ts
@@ -0,0 +1,93 @@
+import assert from "node:assert";
+import { PlayNew, PlaySelect, PlaySelectWithQueueStates } from "./drizzleTypes.js";
+import { PlayInputNew } from "./drizzleTypes.js";
+import { QueueStateNew } from "./drizzleTypes.js";
+import { ComponentNew } from "./drizzleTypes.js";
+import { MarkOptional, MarkRequired } from "ts-essentials";
+import { CLIENT_DEAD_QUEUE, DeadLetterScrobble, ErrorLike, PlayObject } from "../../../../core/Atomic.js";
+import dayjs, { Dayjs } from "dayjs";
+import { asPlay } from "../../../../core/PlayMarshalUtils.js";
+import { playContentBasicInvariantTransform, playMbidIdentifier } from "../../../utils/PlayComparisonUtils.js";
+import { hashObject } from "../../../utils/StringUtils.js";
+import { messageWithCauses } from "../../../utils/ErrorUtils.js";
+
+export const generateComponentEntity = (data: MarkOptional): ComponentNew => {
+ assert(data.name !== undefined, 'Must provide name');
+ return {
+ ...data,
+ uid: data.uid ?? data.name
+ };
+}
+
+export type PlayEntityOpts = Partial> & { error?: ErrorLike };
+
+export const generatePlayEntity = (play: PlayObject, opts: PlayEntityOpts = {}): PlayNew => {
+ const {
+ seenAt = dayjs(),
+ state = 'queued',
+ playedAt = play.data.playDate,
+ ...restOpts
+ } = opts;
+ let playHash: string = undefined;
+ try {
+ playHash = hashObject(playContentBasicInvariantTransform(play).data);
+ } catch (e) {
+ // swallow
+ }
+ const data: PlayNew = {
+ play,
+ playHash,
+ state,
+ playedAt,
+ seenAt: play.meta.seenAt ?? seenAt,
+ ...restOpts
+ };
+ const mbidId = playMbidIdentifier(play);
+ if(mbidId !== undefined) {
+ data.mbidIdentifier = mbidId;
+ }
+ return data;
+}
+
+export type PlayHydateOptions = 'asPlay' | 'id' | 'uid';
+
+export const hydratePlaySelect = (select: PlaySelect, opts: PlayHydateOptions[] = ['id','uid']): PlayObject => {
+ if(opts.length === 0) {
+ return select.play;
+ }
+
+ let res = select.play;
+ // if(opts.includes('asPlay')) {
+ // res = asPlay(res);
+ // }
+ if(opts.includes('uid')) {
+ res.uid = select.uid;
+ //res.meta.dbUid = select.uid;
+ }
+ if(opts.includes('id')) {
+ res.id = select.id;
+ //res.meta.dbId = select.id;
+ }
+ return res;
+}
+
+export const playSelectToDeadScrobble = (select: PlaySelectWithQueueStates): DeadLetterScrobble => {
+ const deadQueue = select.queueStates.find(x => x.queueName === CLIENT_DEAD_QUEUE);
+ return {
+ play: select.play,
+ id: select.uid,
+ source: select.play.meta.source,
+ retries: deadQueue.retries,
+ lastRetry: deadQueue.updatedAt,
+ error: deadQueue.error as unknown as string,
+ status: deadQueue.queueStatus as 'queued' | 'failed'
+ }
+}
+
+export const generateInputEntity = (data: PlayInputNew): PlayInputNew => {
+ return data;
+}
+
+export const generateQueueStateEntity = (data: QueueStateNew): QueueStateNew => {
+ return data;
+}
\ No newline at end of file
diff --git a/src/backend/common/database/drizzle/logContext.ts b/src/backend/common/database/drizzle/logContext.ts
new file mode 100644
index 000000000..3901f6c00
--- /dev/null
+++ b/src/backend/common/database/drizzle/logContext.ts
@@ -0,0 +1,71 @@
+import { Logger } from '@foxxmd/logging'
+import { AsyncLocalStorage } from 'async_hooks'
+
+// based on https://numeric.substack.com/p/upgrading-drizzleorm-logging-with
+interface QueryContext {
+ queryKey: string
+ startTime: number
+ queries: { sql?: string, params?: unknown[] }[]
+}
+
+const queryStorage = new AsyncLocalStorage()
+
+function wrapQuery(queryKey: string, fn: () => Promise): Promise {
+ return queryStorage.run(
+ {
+ queryKey,
+ startTime: Date.now(),
+ queries: []
+ },
+ fn
+ )
+}
+
+function getContext(): QueryContext | undefined {
+ return queryStorage.getStore()
+}
+
+export function addToContext(data: { sql?: string, params?: unknown[] }): void {
+ const context = getContext()
+ if (context) {
+ context.queries.push(data);
+ }
+}
+
+/**
+ * Log all queries made by drizzle during the execution of a promise
+ *
+ * use second parameter to configure when logging occurs
+ * * true => log everything (default)
+ * * false => log nothing, skips asyncstorage entirely
+ * * 'error' => only log if promise throws
+ *
+ */
+export async function executeQuery(queryKey: string, queryPromise: () => Promise, logger: Logger, when: boolean | 'error' = true) {
+ if(when === false) {
+ try {
+ return await queryPromise();
+ } catch (e) {
+ throw e;
+ }
+ }
+ return wrapQuery(queryKey, async () => {
+ try {
+ const results = await queryPromise()
+
+ if (when !== 'error') {
+ // Query is done - grab everything from context
+ const context = getContext()
+ const executionTime = context ? Date.now() - context.startTime : 0
+ logger.info({ labels: ['DB Query', queryKey], queries: context?.queries }, `Execution Complete in ${executionTime}ms`);
+ }
+
+ return results
+ } catch (error) {
+ const context = getContext()
+ const executionTime = context ? Date.now() - context.startTime : 0;
+ logger.warn({ labels: ['DB Query', queryKey], queries: context?.queries }, `Execution failed in ${executionTime}ms`);
+ throw error
+ }
+ })
+}
\ No newline at end of file
diff --git a/src/backend/common/database/drizzle/migrations/20260509011012_dizzy_naoko/migration.sql b/src/backend/common/database/drizzle/migrations/20260509011012_dizzy_naoko/migration.sql
new file mode 100644
index 000000000..ef685e580
--- /dev/null
+++ b/src/backend/common/database/drizzle/migrations/20260509011012_dizzy_naoko/migration.sql
@@ -0,0 +1,83 @@
+CREATE TABLE "components" (
+ "id" serial PRIMARY KEY,
+ "uid" varchar(200) NOT NULL,
+ "mode" varchar(15) NOT NULL,
+ "type" varchar(50) NOT NULL,
+ "name" varchar NOT NULL,
+ "countLive" integer DEFAULT 0 NOT NULL,
+ "countNonLive" integer DEFAULT 0 NOT NULL,
+ "createdAt" timestamp
+);
+--> statement-breakpoint
+CREATE TABLE "jobs" (
+ "id" serial PRIMARY KEY,
+ "componentFromId" integer NOT NULL,
+ "componentToId" integer NOT NULL,
+ "name" varchar(200) NOT NULL,
+ "status" varchar(20) DEFAULT 'idle' NOT NULL,
+ "retries" integer DEFAULT 0 NOT NULL,
+ "error" json,
+ "transformOptions" json,
+ "initialParameters" json,
+ "cursor" json,
+ "total" integer,
+ "imported" integer DEFAULT 0 NOT NULL,
+ "scrobbled" integer DEFAULT 0 NOT NULL,
+ "createdAt" timestamp NOT NULL,
+ "updatedAt" timestamp NOT NULL,
+ "completedAt" timestamp
+);
+--> statement-breakpoint
+CREATE TABLE "play_inputs" (
+ "id" serial PRIMARY KEY,
+ "playId" integer NOT NULL,
+ "data" json,
+ "play" jsonb NOT NULL,
+ "createdAt" timestamp
+);
+--> statement-breakpoint
+CREATE TABLE "plays" (
+ "id" serial PRIMARY KEY,
+ "uid" varchar(30) NOT NULL UNIQUE,
+ "componentId" integer,
+ "error" json,
+ "playedAt" timestamp,
+ "seenAt" timestamp,
+ "updatedAt" timestamp NOT NULL,
+ "play" jsonb NOT NULL,
+ "state" varchar(20) NOT NULL,
+ "parentId" integer,
+ "jobId" integer,
+ "playHash" varchar(100),
+ "mbidIdentifier" varchar(100),
+ "compacted" varchar(30)
+);
+--> statement-breakpoint
+CREATE TABLE "play_queue_states" (
+ "id" serial PRIMARY KEY,
+ "playId" integer NOT NULL,
+ "componentId" integer NOT NULL,
+ "queueName" varchar(50) NOT NULL,
+ "queueStatus" varchar(20) DEFAULT 'queued' NOT NULL,
+ "retries" integer DEFAULT 0 NOT NULL,
+ "error" json,
+ "createdAt" timestamp NOT NULL,
+ "updatedAt" timestamp NOT NULL
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX "uid_mode_type_idx" ON "components" ("uid","mode","type");--> statement-breakpoint
+CREATE UNIQUE INDEX "play_input_id_idx" ON "play_inputs" ("playId");--> statement-breakpoint
+CREATE INDEX "play_parent_id_idx" ON "plays" ("parentId");--> statement-breakpoint
+CREATE INDEX "play_component_id_idx" ON "plays" ("componentId");--> statement-breakpoint
+CREATE UNIQUE INDEX "play_uid_idx" ON "plays" ("uid");--> statement-breakpoint
+CREATE INDEX "play_playedAt_idx" ON "plays" ("playedAt");--> statement-breakpoint
+CREATE INDEX "play_seenAt_idx" ON "plays" ("seenAt");--> statement-breakpoint
+CREATE INDEX "play_queue_state_id_idx" ON "play_queue_states" ("playId");--> statement-breakpoint
+ALTER TABLE "jobs" ADD CONSTRAINT "jobs_componentFromId_components_id_fkey" FOREIGN KEY ("componentFromId") REFERENCES "components"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
+ALTER TABLE "jobs" ADD CONSTRAINT "jobs_componentToId_components_id_fkey" FOREIGN KEY ("componentToId") REFERENCES "components"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
+ALTER TABLE "play_inputs" ADD CONSTRAINT "play_inputs_playId_plays_id_fkey" FOREIGN KEY ("playId") REFERENCES "plays"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
+ALTER TABLE "plays" ADD CONSTRAINT "plays_componentId_components_id_fkey" FOREIGN KEY ("componentId") REFERENCES "components"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
+ALTER TABLE "plays" ADD CONSTRAINT "plays_parentId_plays_id_fkey" FOREIGN KEY ("parentId") REFERENCES "plays"("id") ON DELETE SET NULL ON UPDATE CASCADE;--> statement-breakpoint
+ALTER TABLE "plays" ADD CONSTRAINT "plays_jobId_jobs_id_fkey" FOREIGN KEY ("jobId") REFERENCES "jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
+ALTER TABLE "play_queue_states" ADD CONSTRAINT "play_queue_states_playId_plays_id_fkey" FOREIGN KEY ("playId") REFERENCES "plays"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint
+ALTER TABLE "play_queue_states" ADD CONSTRAINT "play_queue_states_componentId_components_id_fkey" FOREIGN KEY ("componentId") REFERENCES "components"("id") ON DELETE CASCADE ON UPDATE CASCADE;
\ No newline at end of file
diff --git a/src/backend/common/database/drizzle/migrations/20260509011012_dizzy_naoko/snapshot.json b/src/backend/common/database/drizzle/migrations/20260509011012_dizzy_naoko/snapshot.json
new file mode 100644
index 000000000..a31bdd0c3
--- /dev/null
+++ b/src/backend/common/database/drizzle/migrations/20260509011012_dizzy_naoko/snapshot.json
@@ -0,0 +1,1096 @@
+{
+ "version": "8",
+ "dialect": "postgres",
+ "id": "b9a8b9d5-1df4-49b2-a538-3e095bb43c32",
+ "prevIds": [
+ "00000000-0000-0000-0000-000000000000"
+ ],
+ "ddl": [
+ {
+ "isRlsEnabled": false,
+ "name": "components",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "jobs",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "play_inputs",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "plays",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "isRlsEnabled": false,
+ "name": "play_queue_states",
+ "entityType": "tables",
+ "schema": "public"
+ },
+ {
+ "type": "serial",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "components"
+ },
+ {
+ "type": "varchar(200)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "uid",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "components"
+ },
+ {
+ "type": "varchar(15)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "mode",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "components"
+ },
+ {
+ "type": "varchar(50)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "type",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "components"
+ },
+ {
+ "type": "varchar",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "components"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "0",
+ "generated": null,
+ "identity": null,
+ "name": "countLive",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "components"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "0",
+ "generated": null,
+ "identity": null,
+ "name": "countNonLive",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "components"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "createdAt",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "components"
+ },
+ {
+ "type": "serial",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "componentFromId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "componentToId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "varchar(200)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "name",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "varchar(20)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'idle'",
+ "generated": null,
+ "identity": null,
+ "name": "status",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "0",
+ "generated": null,
+ "identity": null,
+ "name": "retries",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "json",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "error",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "json",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "transformOptions",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "json",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "initialParameters",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "json",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "cursor",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "total",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "0",
+ "generated": null,
+ "identity": null,
+ "name": "imported",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "0",
+ "generated": null,
+ "identity": null,
+ "name": "scrobbled",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "createdAt",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updatedAt",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "completedAt",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "type": "serial",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "play_inputs"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "playId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "play_inputs"
+ },
+ {
+ "type": "json",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "data",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "play_inputs"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "play",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "play_inputs"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "createdAt",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "play_inputs"
+ },
+ {
+ "type": "serial",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "type": "varchar(30)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "uid",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "componentId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "type": "json",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "error",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "playedAt",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "seenAt",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updatedAt",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "type": "jsonb",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "play",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "type": "varchar(20)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "state",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "parentId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "jobId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "type": "varchar(100)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "playHash",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "type": "varchar(100)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "mbidIdentifier",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "type": "varchar(30)",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "compacted",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "type": "serial",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "id",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "play_queue_states"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "playId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "play_queue_states"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "componentId",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "play_queue_states"
+ },
+ {
+ "type": "varchar(50)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "queueName",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "play_queue_states"
+ },
+ {
+ "type": "varchar(20)",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "'queued'",
+ "generated": null,
+ "identity": null,
+ "name": "queueStatus",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "play_queue_states"
+ },
+ {
+ "type": "integer",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": "0",
+ "generated": null,
+ "identity": null,
+ "name": "retries",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "play_queue_states"
+ },
+ {
+ "type": "json",
+ "typeSchema": null,
+ "notNull": false,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "error",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "play_queue_states"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "createdAt",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "play_queue_states"
+ },
+ {
+ "type": "timestamp",
+ "typeSchema": null,
+ "notNull": true,
+ "dimensions": 0,
+ "default": null,
+ "generated": null,
+ "identity": null,
+ "name": "updatedAt",
+ "entityType": "columns",
+ "schema": "public",
+ "table": "play_queue_states"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "uid",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "mode",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ },
+ {
+ "value": "type",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "uid_mode_type_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "components"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "playId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "play_input_id_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "play_inputs"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "parentId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "play_parent_id_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "componentId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "play_component_id_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "uid",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": true,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "play_uid_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "playedAt",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "play_playedAt_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "seenAt",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "play_seenAt_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "nameExplicit": true,
+ "columns": [
+ {
+ "value": "playId",
+ "isExpression": false,
+ "asc": true,
+ "nullsFirst": false,
+ "opclass": null
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "with": "",
+ "method": "btree",
+ "concurrently": false,
+ "name": "play_queue_state_id_idx",
+ "entityType": "indexes",
+ "schema": "public",
+ "table": "play_queue_states"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "componentFromId"
+ ],
+ "schemaTo": "public",
+ "tableTo": "components",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "CASCADE",
+ "onDelete": "CASCADE",
+ "name": "jobs_componentFromId_components_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "componentToId"
+ ],
+ "schemaTo": "public",
+ "tableTo": "components",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "CASCADE",
+ "onDelete": "CASCADE",
+ "name": "jobs_componentToId_components_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "jobs"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "playId"
+ ],
+ "schemaTo": "public",
+ "tableTo": "plays",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "CASCADE",
+ "onDelete": "CASCADE",
+ "name": "play_inputs_playId_plays_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "play_inputs"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "componentId"
+ ],
+ "schemaTo": "public",
+ "tableTo": "components",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "CASCADE",
+ "onDelete": "CASCADE",
+ "name": "plays_componentId_components_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "parentId"
+ ],
+ "schemaTo": "public",
+ "tableTo": "plays",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "CASCADE",
+ "onDelete": "SET NULL",
+ "name": "plays_parentId_plays_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "jobId"
+ ],
+ "schemaTo": "public",
+ "tableTo": "jobs",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "CASCADE",
+ "onDelete": "CASCADE",
+ "name": "plays_jobId_jobs_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "plays"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "playId"
+ ],
+ "schemaTo": "public",
+ "tableTo": "plays",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "CASCADE",
+ "onDelete": "CASCADE",
+ "name": "play_queue_states_playId_plays_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "play_queue_states"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "componentId"
+ ],
+ "schemaTo": "public",
+ "tableTo": "components",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "CASCADE",
+ "onDelete": "CASCADE",
+ "name": "play_queue_states_componentId_components_id_fkey",
+ "entityType": "fks",
+ "schema": "public",
+ "table": "play_queue_states"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "components_pkey",
+ "schema": "public",
+ "table": "components",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "jobs_pkey",
+ "schema": "public",
+ "table": "jobs",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "play_inputs_pkey",
+ "schema": "public",
+ "table": "play_inputs",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "plays_pkey",
+ "schema": "public",
+ "table": "plays",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ "id"
+ ],
+ "nameExplicit": false,
+ "name": "play_queue_states_pkey",
+ "schema": "public",
+ "table": "play_queue_states",
+ "entityType": "pks"
+ },
+ {
+ "nameExplicit": false,
+ "columns": [
+ "uid"
+ ],
+ "nullsNotDistinct": false,
+ "name": "plays_uid_key",
+ "schema": "public",
+ "table": "plays",
+ "entityType": "uniques"
+ }
+ ],
+ "renames": []
+}
\ No newline at end of file
diff --git a/src/backend/common/database/drizzle/repositories/BaseRepository.ts b/src/backend/common/database/drizzle/repositories/BaseRepository.ts
new file mode 100644
index 000000000..8c50921f3
--- /dev/null
+++ b/src/backend/common/database/drizzle/repositories/BaseRepository.ts
@@ -0,0 +1,111 @@
+import { childLogger, Logger } from "@foxxmd/logging";
+import { DbConcrete } from "../drizzleUtils.js";
+import { CompareOpKey } from "../drizzleTypes.js";
+import { Dayjs } from "dayjs";
+import { RelationsFieldFilter, eq, inArray } from "drizzle-orm";
+import { loggerNoop } from "../../../MaybeLogger.js";
+import { capitalize } from "../../../../../core/StringUtils.js";
+import { getConfigByTableName, relations, TableName } from "../schema/schema.js";
+
+export interface DrizzleRepositoryOpts {
+ logger?: Logger
+ componentId?: number
+}
+
+export type CompareDateOp = {
+ type: CompareOpKey
+ date: Dayjs
+} | {
+ type: 'between',
+ range: [Dayjs, Dayjs],
+ inclusive?: boolean
+}
+
+export interface PaginatedQueryResponse {
+ limit: number,
+ offset: number
+}
+
+export interface PaginatedResponse {
+ data: T[]
+ meta: PaginatedQueryResponse
+}
+
+export interface ComponentConstrainedRepoOpts {
+ componentId?: number
+}
+
+export abstract class DrizzleBaseRepository {
+
+ logger: Logger;
+ displayName: string;
+ tableName: TableName;
+ table: ReturnType>
+ db: DbConcrete;
+ componentId?: number
+
+ constructor(db: DbConcrete, tableName: TableName, displayName: string, opts: DrizzleRepositoryOpts = {}) {
+ this.db = db;
+ this.displayName = displayName;
+ this.tableName = tableName;
+ this.table = getConfigByTableName(this.tableName);
+ this.logger = childLogger(opts.logger ?? loggerNoop, ['Database', capitalize(displayName)]);
+ this.componentId = opts.componentId;
+ }
+
+ async deleteByIds(ids: number[]): Promise {
+ await this.db.delete(this.table).where(inArray(this.table.id, ids));
+ }
+
+ async updateById(id: number, data: Partial): Promise {
+ await this.db.update(this.table).set(data).where(eq(this.table.id, id));
+ }
+
+ async create(data: typeof this.table.$inferInsert): Promise {
+ const res = await this.db.insert(this.table).values([data]).returning();
+ return res[0];
+ }
+
+ async createMany(data: typeof this.table.$inferInsert[]): Promise {
+ const res = await this.db.insert(this.table).values(data).returning();
+ return res;
+ }
+
+ async findById(id: number): Promise {
+ // const res = await this.db.query[this.tableName].findFirst({
+ // where: {
+ // id
+ // }
+ // });
+ const res = await this.db.select().from(this.table).where(eq(this.table.id, id));
+ if(res.length === 0) {
+ return undefined;
+ }
+ return res[0];
+ }
+}
+
+export class GenericRepository extends DrizzleBaseRepository {
+
+}
+
+export const buildDateCompare = (data: CompareDateOp): RelationsFieldFilter => {
+ let q: RelationsFieldFilter = {};
+ if (data.type !== 'between') {
+ q = {
+ [data.type]: data.date
+ }
+ } else {
+ q = {
+ AND: [
+ {
+ [data.inclusive ?? true ? 'gte' : 'gt']: data.range[0]
+ },
+ {
+ [data.inclusive ?? true ? 'lte' : 'lt']: data.range[1]
+ },
+ ]
+ }
+ }
+ return q;
+}
\ No newline at end of file
diff --git a/src/backend/common/database/drizzle/repositories/ComponentRepository.ts b/src/backend/common/database/drizzle/repositories/ComponentRepository.ts
new file mode 100644
index 000000000..ac19a3251
--- /dev/null
+++ b/src/backend/common/database/drizzle/repositories/ComponentRepository.ts
@@ -0,0 +1,34 @@
+import { Logger } from "drizzle-orm";
+import { DrizzleBaseRepository, DrizzleRepositoryOpts } from "./BaseRepository.js";
+import { DbConcrete } from "../drizzleUtils.js";
+import { ComponentNew, ComponentSelect, FindWhere } from "../drizzleTypes.js";
+import { components } from "../schema/schema.js";
+import { generateComponentEntity } from "../entityUtils.js";
+
+export class DrizzleComponentRepository extends DrizzleBaseRepository<'components'> {
+
+ constructor(db: DbConcrete, opts: DrizzleRepositoryOpts = {}) {
+ super(db, 'components', 'Component', opts);
+ }
+
+ findOrInsert = async (data: { mode: 'source' | 'client', type: string, uid?: string, name?: string }): Promise => {
+ const where: FindWhere<'components'> = {
+ mode: data.mode,
+ type: data.type,
+ uid: data.uid ?? data.name
+ };
+ const component = await this.db.query.components.findFirst({
+ where
+ });
+ if (component !== undefined) {
+ return component;
+ }
+
+ return (await this.db.insert(components).values(generateComponentEntity({
+ uid: data.uid ?? data.name,
+ mode: data.mode,
+ type: data.type,
+ name: data.name
+ })).returning())[0];
+ }
+}
\ No newline at end of file
diff --git a/src/backend/common/database/drizzle/repositories/PlayRepository.ts b/src/backend/common/database/drizzle/repositories/PlayRepository.ts
new file mode 100644
index 000000000..3764b2321
--- /dev/null
+++ b/src/backend/common/database/drizzle/repositories/PlayRepository.ts
@@ -0,0 +1,881 @@
+import { childLogger, Logger, LoggerAppExtras } from "@foxxmd/logging";
+import { DbConcrete } from "../drizzleUtils.js";
+import { loggerNoop } from "../../../MaybeLogger.js";
+import { ErrorLike, PlayObject, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../../../../core/Atomic.js";
+import { generateInputEntity, generatePlayEntity, PlayEntityOpts, hydratePlaySelect, PlayHydateOptions } from "../entityUtils.js";
+import { playInputs, plays, queueStates, relations } from "../schema/schema.js";
+import { PlayNew, PlaySelect, PlayInputNew, FindWhere, FindMany, CompareOpKey, QueueStateSelect, PlayInputSelect, PlaySelectRel, FindWith, PlaySelectWithQueueStates, WhereClause } from "../drizzleTypes.js";;
+import { MarkOptional, MarkRequired, PathValue } from "ts-essentials";
+import { genGroupIdStrFromPlay, removeEmptyArrays, removeUndefinedKeys } from "../../../../utils.js";
+import dayjs, { Dayjs } from "dayjs";
+import { RelationsFieldFilter, eq, inArray, ne, notInArray, desc, asc, and, sql, Placeholder } from "drizzle-orm";
+import { CompactableProperty, RetentionOptions, retentionPlayTypes } from "../../../infrastructure/config/database.js";
+import { shortTodayAwareFormat } from "../../../../../core/TimeUtils.js";
+import { buildDateCompare, CompareDateOp, ComponentConstrainedRepoOpts, DrizzleBaseRepository, DrizzleRepositoryOpts, PaginatedQueryResponse, PaginatedResponse } from "./BaseRepository.js";
+import { asPlay } from "../../../../../core/PlayMarshalUtils.js";
+import assert, { Assert } from "node:assert";
+import { hashObject, parseArrayFromMaybeString } from "../../../../utils/StringUtils.js";
+import { playContentBasicInvariantTransform, playMbidIdentifier } from "../../../../utils/PlayComparisonUtils.js";
+import { comparePlayTemporally, getScrobbleTsSOCDate, getScrobbleTsSOCDateWithContext, getTemporalAccuracyCloseVal, hasAcceptableTemporalAccuracy } from "../../../../utils/TimeUtils.js";
+import { SourceType } from "../../../infrastructure/config/source/sources.js";
+
+// https://github.com/drizzle-team/drizzle-orm/issues/695 may be useful for typing models with relations?
+
+export interface QueueCriteria {
+ queueName: string
+ queueStatus: QueueStateSelect['queueStatus'][] | QueueStateSelect['queueStatus']
+}
+
+export interface PlayWhereOpts {
+ state?: PlaySelect['state'][]
+ stateNot?: PlaySelect['state'][]
+ componentId?: number
+ seenAt?: CompareDateOp
+ playedAt?: CompareDateOp
+ queues?: QueueCriteria[]
+ uid?: string[]
+}
+
+export type WithPlayRelation = 'input' | 'parent' | 'parent-input' | 'queues';
+export interface QueryPlaysOpts extends PlayWhereOpts {
+ sort?: 'seenAt' | 'playedAt'
+ order?: 'asc' | 'desc'
+ with?: WithPlayRelation[]
+ limit?: number
+ offset?: number
+}
+
+export interface HydrateOpts {
+ hydrate?: PlayHydateOptions[]
+}
+
+export type RepositoryCreatePlayOpts = PlayEntityOpts
+ & {
+ input: MarkOptional
+ }
+ & Pick;
+
+type PlayIdentifierPrimitiveMap = {
+ uid: string;
+ id: number;
+};
+const identifierExtractor: { [K in keyof PlayIdentifierPrimitiveMap]: (play: {id: number, uid: string}) => PlayIdentifierPrimitiveMap[K] } = {
+ id: (play) => play.id,
+ uid: (play) => play.uid,
+};
+export class DrizzlePlayRepository extends DrizzleBaseRepository<'plays'> {
+
+ protected hasQueueNextPrepared?: ReturnType
+ protected getQueueNextPrepared?: ReturnType
+ protected getQueuedScrobbleRangePrepared?: ReturnType
+
+ constructor(db: DbConcrete, opts: DrizzleRepositoryOpts = {}) {
+ super(db, 'plays', 'Plays', opts);
+ }
+
+ findByUid = async (uid: string, opts: HydrateOpts & ComponentConstrainedRepoOpts = {}): Promise => {
+ const res = await this.db.query.plays.findFirst({
+ where: {
+ uid,
+ componentId: opts.componentId ?? this.componentId
+ },
+ with: {
+ queueStates: true
+ }
+ });
+ res.play = hydratePlaySelect(res, opts.hydrate);
+ return res;
+ }
+
+ createPlays = async (entitiesOpts: RepositoryCreatePlayOpts[], opts: HydrateOpts = {}) => {
+
+ const {
+ hydrate
+ } = opts;
+ let playRows: PlaySelect[];
+
+ await this.db.transaction(async (tx) => {
+
+ const entitiesData = entitiesOpts.map((data) => {
+ const {
+ play,
+ input,
+ ...rest
+ } = data;
+ return generatePlayEntity(play, { componentId: this.componentId, ...rest });
+ });
+
+ playRows = await tx.insert(plays).values(entitiesData).returning();
+
+ const inputDatas = playRows.map((x, index) => {
+ const {
+ play,
+ input,
+ } = entitiesOpts[index];
+ const {
+ play: inputPlay = play,
+ ...restInput
+ } = input;
+
+ return generateInputEntity({ play: inputPlay, playId: x.id, ...restInput });
+ });
+
+ const inputRow = await tx.insert(playInputs).values(inputDatas);
+
+ });
+
+ return playRows.map(x => ({...x, play: hydratePlaySelect(x, hydrate)}));
+ }
+
+ findPlays = async (args: QueryPlaysOpts, opts: HydrateOpts & ComponentConstrainedRepoOpts = {}): Promise => {
+ const {
+ hydrate,
+ componentId = this.componentId
+ } = opts;
+ // this does not work as type for query variable
+ // it erases the result type for some reason
+ //
+ // Parameters[0]
+
+ // this does work but it is also integrated into FindWith
+ //let withQuery: Parameters[0]['with'] = undefined;
+
+ let query: FindMany<'plays'> = {
+ limit: args.limit,
+ offset: args.offset
+ };
+
+ query.where = buildPlayWhere({componentId: componentId, ...args});
+
+ if (args.sort !== undefined) {
+ query.orderBy = {
+ [args.sort]: args.order ?? 'desc'
+ }
+ } else {
+ query.orderBy = {
+ id: 'asc'
+ }
+ }
+
+ if(args.with !== undefined) {
+ query.with = {};
+ for(const w of args.with) {
+ switch (w) {
+ case 'input':
+ query.with.input = true;
+ break;
+ case 'parent':
+ query.with.parent = true;
+ break;
+ case 'parent-input':
+ query.with.parent = {
+ with: {
+ input: true
+ }
+ };
+ break;
+ case 'queues':
+ query.with.queueStates = true;
+ break;
+ default:
+ throw new Error(`Unknown relation ${w}`);
+ }
+ }
+ }
+ query = removeUndefinedKeys(query);
+ const results = await this.db.query.plays.findMany(query);
+ return results.map((x) => ({...x, play: hydratePlaySelect(x, hydrate)}));
+ }
+
+ findPlayIds = async (args: QueryPlaysOpts, opts: ComponentConstrainedRepoOpts = {}): Promise => {
+ const {
+ componentId = this.componentId
+ } = opts;
+
+ let query: FindMany<'plays'> = {
+ limit: args.limit,
+ offset: args.offset,
+ };
+
+ query.where = buildPlayWhere({componentId: componentId, ...args});
+
+ if (args.sort !== undefined) {
+ query.orderBy = {
+ [args.sort]: args.order ?? 'desc'
+ }
+ } else {
+ query.orderBy = {
+ id: 'asc'
+ }
+ }
+
+ query = removeUndefinedKeys(query);
+ const results = await this.db.query.plays.findMany({
+ limit: args.limit,
+ offset: args.offset,
+ columns: {id: true},
+ orderBy: args.sort !== undefined ? {[args.sort]: args.order ?? 'desc'} : {id: 'asc'},
+ });
+ return results.map((x) => x.id);
+ }
+
+ findPlayIdentifiers = async (args: QueryPlaysOpts, identifier: T, opts: ComponentConstrainedRepoOpts = {}): Promise => {
+ const {
+ componentId = this.componentId,
+ } = opts;
+
+ const results = await this.db.query.plays.findMany({
+ limit: args.limit,
+ offset: args.offset,
+ columns: {id: true, uid: true},
+ orderBy: args.sort !== undefined ? {[args.sort]: args.order ?? 'desc'} : {id: 'asc'},
+ where: buildPlayWhere({componentId: componentId, ...args})
+ });
+
+ // we getting fancy now
+ return results.map(identifierExtractor[identifier]);
+ }
+
+ findPlaysPaginated = async (args: QueryPlaysOpts, opts: HydrateOpts & ComponentConstrainedRepoOpts = {}): Promise> => {
+ const {
+ limit = 100,
+ offset = 0,
+ ...rest
+ } = args;
+ const clampedLimit = Math.min(limit, 100);
+ const res = await this.findPlays({limit: clampedLimit, offset, ...rest}, opts) as T[];
+ return {data: res, meta: {limit: clampedLimit, offset}};
+ }
+
+ // async updateById(id: number, data: Partial): Promise {
+ // if(data.play !== undefined) {
+ // data.play = withoutDbAwareness(data.play);
+ // }
+ // super.updateById(id, data);
+ // }
+
+ setStateById = async (state: PlayNew['state'], ids: number[]): Promise => {
+ const validIds = ids.filter(x => x !== undefined && x !== null);
+ assert(validIds.length > 0, `Should not pass empty array of ids, after filtering, to update state. Original ids list: ${ids}`);
+ await this.db.update(plays).set({state}).where(inArray(plays.id, ids));
+ }
+
+ deletePlays = async (playsData: (Pick | number)[]) => {
+ const ids = playsData.map(x => typeof x === 'number' ? x : x.id);
+ await this.db.delete(plays).where(inArray(plays.id, ids));
+ }
+
+ findPurgablePlayIds = async (olderThanDate: Dayjs, opts: { states?: PlaySelect['state'][], compacted?: string, dateComparer?: 'updatedAt' | 'seenAt' } & ComponentConstrainedRepoOpts = {}): Promise => {
+
+ const {
+ states,
+ compacted,
+ componentId = this.componentId,
+ dateComparer = 'updatedAt'
+ } = opts;
+
+ let where: FindWhere<'plays'> = {
+ component: {
+ id: componentId
+ },
+ [dateComparer]: {
+ lte: olderThanDate
+ },
+ NOT: {
+ children: {}
+ }
+ };
+
+ if (states !== undefined) {
+ where.state = {
+ in: states
+ }
+ }
+
+ if(compacted !== undefined) {
+ where.compacted = {
+ OR: [
+ {
+ isNull: true
+ },
+ {
+ NOT: {
+ eq: compacted
+ }
+ }
+ ]
+ }
+ }
+
+ const rows = await this.db.query.plays.findMany({
+ columns: {
+ id: true
+ },
+ where,
+ orderBy: {
+ id: 'asc'
+ }
+ });
+
+ return rows.map(x => x.id);
+ }
+
+ public retentionCleanup = async (componentType: string, retentionOpts: RetentionOptions & ComponentConstrainedRepoOpts) => {
+
+ const loggerDel = childLogger(this.logger, ['Retention', 'Delete']);
+ const loggerCom = childLogger(this.logger, ['Retention', 'Compact']);
+ let summaryDelStates: string[] = [];
+ let summaryCompactStates: string[] = [];
+
+ loggerDel.debug('Starting cleanup...');
+ for(const retentionType of retentionPlayTypes) {
+ try {
+ const date = dayjs().subtract(retentionOpts.deleteAfter[retentionType].asMilliseconds());
+ let state: PlaySelect['state'];
+ if(retentionType === 'completed') {
+ state = componentType === 'source' ? 'discovered' : 'scrobbled';
+ } else {
+ state = retentionType;
+ }
+ loggerDel.trace(`Finding '${retentionType}' plays older than ${shortTodayAwareFormat(date)}...`);
+ const ids = await this.findPurgablePlayIds(date, {states: [state], componentId: retentionOpts.componentId});
+ loggerDel.trace(`Found ${ids.length} '${retentionType}' plays`);
+ if(ids.length === 0) {
+ summaryDelStates.push(`No '${retentionType}' Plays older than ${shortTodayAwareFormat(date)}`);
+ } else {
+ loggerDel.trace(`Deleting ${ids.length} '${retentionType}' plays`);
+ await this.deletePlays(ids);
+ loggerDel.trace(`'${retentionType}' plays deleted!`);
+ summaryDelStates.push(`${ids.length} '${retentionType}' Plays older than ${shortTodayAwareFormat(date)}`)
+ }
+ } catch (e) {
+ loggerDel.warn(new Error(`Failed to perform retention cleanup on '${retentionType}' type`, {cause: e}));
+ }
+ }
+ loggerDel.verbose(`Cleanup done! Summary:\n${summaryDelStates.join(' | ')}`);
+
+ if(retentionOpts.compact.length === 0) {
+ loggerCom.debug('Compacting is disabled, skipping cleanup.');
+ return;
+ }
+
+ const compactTypes = retentionOpts.compact;
+ let compactedFlags: CompactableProperty[] = [];
+ if(compactTypes.includes('input')) {
+ compactedFlags.push('input');
+ }
+ if(compactTypes.includes('transform')) {
+ compactedFlags.push('transform');
+ }
+
+ loggerCom.debug('Starting cleanup...');
+ for(const retentionType of retentionPlayTypes) {
+ if(retentionOpts.compactAfter[retentionType] === false) {
+ summaryCompactStates.push(`Skipped ${retentionType}`);
+ continue;
+ }
+ try {
+ const date = dayjs().subtract(retentionOpts.compactAfter[retentionType].asMilliseconds());
+ let state: PlaySelect['state'];
+ if(retentionType === 'completed') {
+ state = componentType === 'source' ? 'discovered' : 'scrobbled';
+ } else {
+ state = retentionType;
+ }
+ loggerCom.trace(`Finding '${retentionType}' plays older than ${shortTodayAwareFormat(date)}...`);
+ const ids = await this.findPurgablePlayIds(date, {compacted: compactedFlags.join('-'), states: [state], dateComparer: 'seenAt', componentId: retentionOpts.componentId});
+ loggerCom.trace(`Found ${ids.length} '${retentionType}' plays`);
+ if(ids.length === 0) {
+ summaryDelStates.push(`No '${retentionType}' Plays older than ${shortTodayAwareFormat(date)}`);
+ } else {
+ for(const id of ids) {
+ let compactedPlay: PlayObject;
+ if(compactTypes.includes('input')) {
+ await this.db.update(playInputs).set({
+ data: {removedReason: 'Removed by compaction'}
+ }).where(eq(playInputs.playId, id));
+ }
+ if(compactTypes.includes('transform')) {
+ const playRow = await this.db.query.plays.findFirst({where: {id: id}});
+ if(playRow === undefined) {
+ // uhh shouldn't be
+ loggerCom.warn(`No Play found with ID ${id}, but it should have been...`);
+ continue;
+ }
+
+ compactedPlay = playRow.play;
+ compactedPlay.meta.lifecycle.steps = compactedPlay.meta.lifecycle.steps.map(x => {
+ if(x.inputs == undefined) {
+ return x;
+ }
+ return {...x, inputs: x.inputs.map(y => ({type: y.type, input: 'Removed by compaction'}))};
+ });
+ }
+
+ const updater = this.db.update(plays);
+ const vals: Parameters[0] = {
+ compacted: compactedFlags.join('-')
+ };
+ if(compactedPlay !== undefined) {
+ vals.play = compactedPlay;
+ }
+ await this.db.update(plays).set(vals).where(eq(plays.id, id));
+ }
+ loggerCom.trace(`Compacted ${ids.length} '${retentionType}' plays`);
+ summaryCompactStates.push(`${ids.length} '${retentionType}' Plays older than ${shortTodayAwareFormat(date)}`)
+ }
+ } catch (e) {
+ loggerCom.warn(new Error(`Failed to perform retention cleanup on '${retentionType}' type`, {cause: e}));
+ }
+ }
+
+ loggerCom.verbose(`Cleanup done! Summary:\n${summaryDelStates.join(' | ')}`);
+ }
+
+ protected prepareGetQueueNext = () => this.db.query.plays.findFirst({
+ where: {
+ componentId: sql.placeholder('componentId'),
+ queueStates: {
+ queueName: sql.placeholder('queueName'),
+ queueStatus: 'queued',
+ retries: {
+ lte: sql.placeholder('retries')
+ }
+ },
+ },
+ with: {
+ queueStates: true
+ },
+ orderBy: {
+ seenAt: 'asc'
+ },
+ }).prepare()
+
+ public getQueueNext = async (queueName: string, opts: {order?: 'asc' | 'desc', retries?: number} & ComponentConstrainedRepoOpts = {}): Promise => {
+ const {
+ retries = 0,
+ order = 'asc',
+ componentId = this.componentId
+ } = opts;
+
+ // let where: FindWhere<'plays'> = {
+ // componentId
+ // }
+
+ // if(retries !== undefined) {
+ // where.queueStates = {
+ // queueName,
+ // queueStatus: 'queued',
+ // retries: {
+ // lte: retries
+ // }
+ // }
+ // } else {
+ // where.queueStates = {
+ // queueName,
+ // queueStatus: 'queued'
+ // }
+ // }
+
+ // const res = await this.db.query.plays.findFirst({
+ // where: where,
+ // orderBy: {
+ // seenAt: order
+ // },
+ // with: {
+ // queueStates: true
+ // }
+ // });
+
+ if(this.getQueueNextPrepared === undefined) {
+ this.getQueueNextPrepared = this.prepareGetQueueNext();
+ }
+
+ const res = await this.getQueueNextPrepared.execute({queueName, retries, componentId});
+
+ if(res === undefined) {
+ return undefined;
+ }
+ res.play = hydratePlaySelect(res); // asPlay(res.play);
+ return res;
+ }
+
+ protected prepareHasQueueNext = () => this.db.query.plays.findFirst({
+ columns: {
+ id: true
+ },
+ where: {
+ componentId: this.componentId,
+ queueStates: {
+ queueName: sql.placeholder('queueName'),
+ queueStatus: 'queued',
+ retries: {
+ lte: sql.placeholder('retries')
+ }
+ }
+ }
+ }).prepare()
+
+ public hasQueueNext = async (queueName: string, retries: number = 0): Promise => {
+ if(this.hasQueueNextPrepared === undefined) {
+ this.hasQueueNextPrepared = this.prepareHasQueueNext();
+ }
+ const nextId = await this.hasQueueNextPrepared.execute({queueName, retries});
+ return nextId !== undefined;
+ }
+
+ protected prepareGetQueuedScrobbleRange = () => this.db.query.plays.findMany({
+ where: {
+ componentId: this.componentId,
+ queueStates: {
+ queueName: sql.placeholder('queueName'),
+ queueStatus: 'queued',
+ retries: {
+ lte: sql.placeholder('retries')
+ }
+ },
+ },
+ orderBy: {
+ seenAt: 'asc',
+ },
+ limit: sql.placeholder('limit')
+ }).prepare()
+
+ public getQueuedScrobbleRange = async (queueName: string, opts: {retries?: number, limit?: number} = {}): Promise => {
+ if(this.getQueuedScrobbleRangePrepared === undefined) {
+ this.getQueuedScrobbleRangePrepared = this.prepareGetQueuedScrobbleRange();
+ }
+ const res = await this.getQueuedScrobbleRangePrepared.execute({queueName, retries: opts.retries ?? 0, limit: opts.limit ?? 30});
+ return res.map(x => x.play);
+ }
+
+ public getQueued = async (queueName: string, opts: {
+ order?: 'asc' | 'desc',
+ limit?: number,
+ offset?: number,
+ retries?: number,
+ } & ComponentConstrainedRepoOpts & HydrateOpts = {}
+ ): Promise<{data: PlaySelect[], meta: PaginatedQueryResponse}> => {
+ const {
+ order = 'asc',
+ limit = 100,
+ offset = 0,
+ retries,
+ componentId = this.componentId,
+ hydrate
+ } = opts;
+ let where: FindWhere<'plays'> = {
+ componentId
+ }
+ if(retries !== undefined) {
+ where.queueStates = {
+ queueName,
+ queueStatus: 'queued',
+ retries: {
+ lte: retries
+ }
+ }
+ } else {
+ where.queueStates = {
+ queueName,
+ queueStatus: 'queued'
+ }
+ }
+ const res = await this.db.query.plays.findMany({
+ where,
+ orderBy: {
+ seenAt: order
+ },
+ limit,
+ offset
+ });
+ return {data: res.map(x => ({...x, play: hydratePlaySelect(x, hydrate)})), meta: {limit, offset}};
+ }
+
+ public checkExisting = async (play: PlayObject, opts: {queueName?: string, states?: PlaySelect['state'][], taAccuracy?: TemporalAccuracy[]} & ComponentConstrainedRepoOpts = {}): Promise => {
+ const {
+ queueName,
+ componentId = this.componentId,
+ taAccuracy = TA_DEFAULT_ACCURACY,
+ states
+ } = opts;
+ const hash = hashObject(playContentBasicInvariantTransform(play).data);
+
+ // we get all plays with a play date between playdate - (source accuracy) AND (playDateCompleted or playDate) + (source accuracy)
+ // which we can then use with temporal comparison to make sure we are comparing the correct dates
+ //
+ // this isn't as fast as just comparing playDate directly but its still much faster/cheaper than paginating plays and doing everything in-memory
+ const dateGranularity = getTemporalAccuracyCloseVal(play.meta.source as SourceType);
+ let endRange: Dayjs;
+ if(play.data.playDateCompleted !== undefined) {
+ // this will be present if source reports it
+ // or we tracked it live with MemorySource
+ endRange = play.data.playDateCompleted.add(dateGranularity, 's');
+ } else {
+ endRange = play.data.playDate.add(dateGranularity, 's');
+ }
+ let where: FindWhere<'plays'> = {
+ componentId,
+ playedAt: buildDateCompare(getTemporallyCloseDateCompareOp(play)),
+ };
+
+ if(queueName !== undefined) {
+ where.queueStates = {
+ queueName,
+ queueStatus: 'queued'
+ }
+ }
+ if(states !== undefined) {
+ where.state = {
+ in: states
+ }
+ }
+
+ const mbidId = playMbidIdentifier(play);
+ if(mbidId !== undefined) {
+ where.AND = [
+ {
+ OR: [
+ {
+ playHash: hash
+ },
+ {
+ mbidIdentifier: mbidId
+ }
+ ]
+ }
+ ]
+ } else {
+ where.playHash = hash;
+ }
+
+ const res = await this.db.query.plays.findMany({
+ where,
+ with: {
+ queueStates: true
+ }
+ });
+ if(res.length === 0) {
+ return undefined;
+ }
+ return res.map(x => ({...x, play: hydratePlaySelect(x)})).find(x => {
+ const temporalComparison = comparePlayTemporally(x.play, play);
+ return hasAcceptableTemporalAccuracy(temporalComparison.match, taAccuracy)
+ })
+ }
+
+ public getTemporallyClosePlays = async (play: PlayObject, opts: {states?: PlaySelect['state'][], bufferTime?: number} & { with?: WithPlayRelation[] } & ComponentConstrainedRepoOpts = {}): Promise => {
+ const {
+ componentId = this.componentId,
+ bufferTime,
+ states,
+ with: qWith
+ } = opts;
+
+ let query: FindMany<'plays'> = {};
+
+ let where: FindWhere<'plays'> = {
+ componentId,
+ playedAt: buildDateCompare(getTemporallyCloseDateCompareOp(play, {bufferTime})),
+ };
+ if(states !== undefined) {
+ where.state = {
+ in: states
+ }
+ }
+ query.where = where;
+
+ return ((await this.db.query.plays.findMany({
+ where,
+ with: buildPlayWith(qWith)
+ })) as PlaySelectRel[]).map(x => ({...x, play: hydratePlaySelect(x)}));
+ }
+}
+
+export const getTemporallyCloseDateCompareOp = (play: PlayObject, opts: {bufferTime?: number, useCompleted?: boolean} = {}): CompareDateOp => {
+ const {
+ // use either provided arg or default to using source granularity
+ bufferTime = getTemporalAccuracyCloseVal(play.meta.source as SourceType),
+ useCompleted = true
+ } = opts;
+ // we get all plays with a play date between playdate - (buffer) AND (playDateCompleted or playDate) + (buffer)
+ let endRange: Dayjs;
+ if(play.data.playDateCompleted !== undefined && useCompleted) {
+ // this will be present if source reports it
+ // or we tracked it live with MemorySource
+ endRange = play.data.playDateCompleted.add(bufferTime, 's');
+ } else {
+ endRange = play.data.playDate.add(bufferTime, 's');
+ }
+ return {
+ type: 'between',
+ range: [play.data.playDate.subtract(bufferTime, 's'), endRange]
+ }
+}
+
+export const buildPlayWith = (args: WithPlayRelation[] | undefined): FindWith<'plays'> | undefined => {
+ if(args === undefined) {
+ return undefined;
+ }
+ const qWith: FindWith<'plays'> = {};
+ for(const w of args) {
+ switch (w) {
+ case 'input':
+ qWith.input = true;
+ break;
+ case 'parent':
+ qWith.parent = true;
+ break;
+ case 'parent-input':
+ qWith.parent = {
+ with: {
+ input: true
+ }
+ };
+ break;
+ case 'queues':
+ qWith.queueStates = true;
+ break;
+ default:
+ throw new Error(`Unknown relation ${w}`);
+ }
+ }
+ return qWith;
+}
+
+export const buildPlayWhere = (args: PlayWhereOpts): WhereClause<'plays'> => {
+ // old way
+ // let where: Parameters<(ReturnType)['query']['plays']['findMany']>[0]['where'] = {
+ // };
+ let where: FindWhere<'plays'> = {
+ componentId: args.componentId
+ };
+ if (args.state !== undefined) {
+ where.state = {
+ in: args.state
+ }
+ }
+ if(args.stateNot !== undefined) {
+ where.state = {
+ NOT: {
+ in: args.stateNot
+ }
+ }
+ }
+ if (args.seenAt !== undefined) {
+ where.seenAt = buildDateCompare(args.seenAt);
+ }
+ if (args.playedAt !== undefined) {
+ where.playedAt = buildDateCompare(args.playedAt);
+ }
+ if(args.uid !== undefined) {
+ where.uid = {
+ in: args.uid
+ }
+ }
+ const {
+ queues = []
+ } = args;
+ if(queues.length > 0) {
+ // need to do this optimistically even if we overwrite with only 1 condition later
+ where.queueStates = {
+ OR: []
+ }
+ // so that we can use this type
+ // or else assigning an array to OR using only `typeof where.queueStates` causes a type error
+ let queueWhere: typeof where.queueStates.OR[0][] = [];
+ for(const q of queues) {
+ queueWhere.push(
+ {
+ queueName: q.queueName,
+ queueStatus: typeof q.queueStatus === 'string' ? q.queueStatus : {
+ in: q.queueStatus
+ }
+ }
+ )
+ }
+ if(queueWhere.length === 1) {
+ where.queueStates = queueWhere[0];
+ } else {
+ where.queueStates = {
+ OR: queueWhere
+ }
+ }
+ }
+ return where;
+}
+
+export const playToRepositoryCreatePlayOpts = (data: MarkOptional): RepositoryCreatePlayOpts => {
+ const {
+ play: {
+ meta: {
+ lifecycle: {
+ input,
+ original,
+ ...lifecycleRest
+ } = {},
+ ...metaRest
+ },
+ ...playRest
+ },
+ ...rest
+ } = data;
+
+ return {
+ play: {
+ ...playRest,
+ meta: {
+ ...metaRest,
+ // @ts-expect-error
+ lifecycle: {
+ ...lifecycleRest
+ }
+ }
+ },
+ ...rest,
+ input: {
+ play: original,
+ data: input
+ }
+ }
+}
+
+export type RequestPlayQuery = Partial< Record, string>>;
+
+export const queryArgsFromRequest = (rec: RequestPlayQuery): QueryPlaysOpts => {
+
+ const {
+ state,
+ stateNot,
+ uid,
+ with: withQuery,
+ seenAt,
+ playedAt,
+ limit,
+ sort,
+ order,
+ offset,
+ componentId,
+ queues,
+ ...rest
+ } = rec;
+
+ let queryArgs: QueryPlaysOpts = removeEmptyArrays({
+ state: parseArrayFromMaybeString(state) as PlaySelect['state'][],
+ stateNot: parseArrayFromMaybeString(stateNot) as PlaySelect['state'][],
+ uid: parseArrayFromMaybeString(uid),
+ with: parseArrayFromMaybeString(withQuery) as WithPlayRelation[],
+ sort: sort as 'playedAt' | 'seenAt',
+ order: order as 'asc' | 'desc',
+ ...rest
+ });
+
+ if(limit !== undefined) {
+ queryArgs.limit = Number.parseInt(limit);
+ }
+ if(offset !== undefined) {
+ queryArgs.offset = Number.parseInt(offset);
+ }
+
+ return queryArgs;
+}
\ No newline at end of file
diff --git a/src/backend/common/database/drizzle/repositories/QueueRepository.ts b/src/backend/common/database/drizzle/repositories/QueueRepository.ts
new file mode 100644
index 000000000..54c8d379d
--- /dev/null
+++ b/src/backend/common/database/drizzle/repositories/QueueRepository.ts
@@ -0,0 +1,41 @@
+import { eq, and, lte, inArray } from "drizzle-orm";
+import { DrizzleBaseRepository, DrizzleRepositoryOpts } from "./BaseRepository.js";
+import { DbConcrete } from "../drizzleUtils.js";
+import { QueueStateSelect } from "../drizzleTypes.js";
+import { queueStates } from "../schema/schema.js";
+import { CLIENT_DEAD_QUEUE } from "../../../../../core/Atomic.js";
+export class DrizzleQueueRepository extends DrizzleBaseRepository<'queueStates'> {
+
+ constructor(db: DbConcrete, opts: DrizzleRepositoryOpts = {}) {
+ super(db, 'queueStates', 'Queue', opts);
+ }
+
+ public deadFailedToQueue = async (componentId: number, retries: number): Promise => {
+ await this.db.update(queueStates).set({
+ queueStatus: 'queued',
+ }).where(and(
+ eq(queueStates.componentId, componentId),
+ lte(queueStates.retries, retries),
+ eq(queueStates.queueStatus, 'failed'),
+ eq(queueStates.queueName, CLIENT_DEAD_QUEUE)
+ ));
+ }
+
+ public failedQueueToCompleted = async (componentId: number): Promise => {
+ await this.db.update(queueStates).set({
+ queueStatus: 'completed',
+ }).where(and(
+ eq(queueStates.componentId, componentId),
+ eq(queueStates.queueStatus, 'queued'),
+ eq(queueStates.queueName, CLIENT_DEAD_QUEUE)
+ ));
+ }
+
+ public getQueueCount = async (componentId: number, queueNames: string[], queueStatus: QueueStateSelect['queueStatus'][] = ['queued']): Promise => {
+ return await this.db.$count(queueStates, and(
+ eq(queueStates.componentId, componentId),
+ inArray(queueStates.queueName, queueNames),
+ inArray(queueStates.queueStatus, queueStatus)
+ ));
+ }
+}
\ No newline at end of file
diff --git a/src/backend/common/database/drizzle/schema/schema.ts b/src/backend/common/database/drizzle/schema/schema.ts
new file mode 100644
index 000000000..b21a48460
--- /dev/null
+++ b/src/backend/common/database/drizzle/schema/schema.ts
@@ -0,0 +1,239 @@
+import { integer, serial as primaryInt, pgTable as table, text, varchar, json, index, uniqueIndex, customType, AnyPgColumn, timestamp } from "drizzle-orm/pg-core";
+import { defineRelations } from 'drizzle-orm';
+import dayjs, { Dayjs } from "dayjs";
+import { nanoid } from "nanoid";
+import { ErrorLike, PlayObject } from "../../../../../core/Atomic.js";
+import { asPlayCheap } from "../../../../../core/PlayMarshalUtils.js";
+import { ExternalMetadataTerm, PlayTransformPartsConfig, SearchAndReplaceTerm } from "../../../infrastructure/Transform.js";
+import { JobRangeCount, JobRangeTime } from "../../../infrastructure/Job.js";
+
+const DayjsTimestamp = customType<
+ {
+ data: Dayjs;
+ driverData: string;
+ }
+>({
+ dataType() {
+ return 'timestamp'
+ },
+ toDriver(value: Dayjs): string {
+ return value.toISOString();
+ },
+ fromDriver(value: string): Dayjs {
+ return dayjs(value);
+ },
+});
+
+const PlayJson = customType<
+ {
+ data: PlayObject;
+ driverData: string;
+ }
+>({
+ dataType() {
+ return 'jsonb'
+ },
+ toDriver(value: PlayObject): string {
+ const {
+ // meta: {
+ // // dbId,
+ // // dbUid,
+ // ...metaRest
+ // },
+ id,
+ uid,
+ ...rest
+ } = value;
+ return JSON.stringify(rest);
+ },
+ fromDriver(value: any): PlayObject {
+ return asPlayCheap(value);
+ },
+});
+
+
+export const plays = table("plays", {
+ id: primaryInt().primaryKey(),
+ uid: varchar({ length: 30 }).notNull().unique().$defaultFn(() => nanoid(20)),
+ componentId: integer().references(() => components.id, {onDelete: 'cascade', onUpdate: 'cascade'}),
+ error: json().$type(),
+ playedAt: DayjsTimestamp('playedAt'),
+ seenAt: DayjsTimestamp('seenAt'),
+ updatedAt: DayjsTimestamp('updatedAt').notNull().$defaultFn(() => dayjs()),
+ play: PlayJson('play').notNull(), // text({ mode: 'json' }).notNull().$type(),
+ state: varchar({enum: ['queued','discovered','discarded','scrobbled','failed','duped'], length: 20}).notNull(),
+ // https://orm.drizzle.team/docs/indexes-constraints#foreign-key
+ parentId: integer().references((): AnyPgColumn => plays.id, {onDelete: 'set null', onUpdate: 'cascade'}),
+ jobId: integer().references(() => jobs.id, {onDelete: 'cascade', onUpdate: 'cascade'}),
+ playHash: varchar({length: 100}),
+ mbidIdentifier: varchar({length: 100}),
+ compacted: varchar({length: 30})
+}, (table) => [
+ index("play_parent_id_idx").on(table.parentId),
+ index("play_component_id_idx").on(table.componentId),
+ uniqueIndex("play_uid_idx").on(table.uid),
+ index("play_playedAt_idx").on(table.playedAt),
+ index("play_seenAt_idx").on(table.seenAt)
+]);
+
+export const playInputs = table("play_inputs", {
+ id: primaryInt().primaryKey(),
+ playId: integer().notNull().references(() => plays.id, {onDelete: 'cascade', onUpdate: 'cascade'}),
+ data: json().$type