diff --git a/.gitignore b/.gitignore index 2b5fa3b0..ae6e1cbb 100644 --- a/.gitignore +++ b/.gitignore @@ -151,7 +151,10 @@ tmp-* *storybook.log storybook-static -lib + +*.db +*.db.* +*.db-*lib *.db *.db.* *.db-*lib \ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 00000000..973eac00 --- /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: 'sqlite', + dbCredentials: { + url: path.resolve(configDir, process.env.DB_FILE_NAME! ?? 'ms.db'), + }, +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 28d54273..7e822728 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@kenyip/backoff-strategies": "^1.0.4", "@keyv/valkey": "^1.0.8", "@lukehagar/plexjs": "^0.39.0", + "@platformatic/job-queue": "^0.5.0", "@supercharge/promise-pool": "^3.0.0", "@svrooij/sonos": "^2.5.0", "@xhayper/discord-rpc": "^1.3.0", @@ -47,6 +48,8 @@ "dbus-ts": "^0.0.7", "discord.js": "^14.26.0", "dotenv": "^10.0.0", + "drizzle-kit": "^1.0.0-beta.22", + "drizzle-orm": "^1.0.0-beta.22", "express": "^5.2.1", "express-session": "^1.19.0", "fast-equals": "^6.0.0", @@ -129,7 +132,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", @@ -1055,6 +1058,12 @@ "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==", + "license": "Apache-2.0" + }, "node_modules/@dword-design/chdir": { "version": "4.0.0", "dev": true, @@ -2018,6 +2027,13 @@ "@swc/helpers": "^0.5.0" } }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT", + "optional": true + }, "node_modules/@iovalkey/commands": { "version": "0.1.0", "license": "MIT" @@ -2174,6 +2190,18 @@ "@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==", + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@kenyip/backoff-strategies": { "version": "1.0.4", "license": "MIT" @@ -2605,6 +2633,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "license": "MIT", @@ -2613,6 +2647,103 @@ "node": ">=14" } }, + "node_modules/@platformatic/job-queue": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@platformatic/job-queue/-/job-queue-0.5.0.tgz", + "integrity": "sha512-vAD4HmVy3IdDhv74+282j6YS9N8wbziWT/T1IPBQNsQO+ObKiZNfDQFeakAIEUp0w80vFJyrgy42knxJ97ACgQ==", + "license": "Apache-2.0", + "dependencies": { + "pino": "^10.3.1" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "fast-write-atomic": "^0.4.0", + "iovalkey": "^0.2.0", + "pg": "^8.20.0" + } + }, + "node_modules/@platformatic/job-queue/node_modules/iovalkey": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.2.2.tgz", + "integrity": "sha512-7eVmLOYV2UamZ/YPXuUwTu/4zBDxXcfjj/wmOwlKBBhU2qjg60Th0Y/cqfED3OxNAhc6hUV2Ft4eQMCKi2EMpQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@platformatic/job-queue/node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/@platformatic/job-queue/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/@platformatic/job-queue/node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@platformatic/job-queue/node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "license": "BSD-3-Clause" @@ -4288,10 +4419,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": { @@ -7205,40 +7338,675 @@ }, "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/discord.js/node_modules/undici": { + "version": "6.24.1", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dotenv": { + "version": "10.0.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, + "node_modules/drizzle-kit": { + "version": "1.0.0-beta.22", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-1.0.0-beta.22.tgz", + "integrity": "sha512-9HTZuQRljQKTgCx4UhiGn8KYYfHGk4+B/bRR1714W67kz0qgJvdrG527i8rQD8uUyET9UTGR1u8syySJD4znGw==", + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, - "node_modules/discord.js/node_modules/undici": { - "version": "6.24.1", + "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" + ], "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=18.17" + "node": ">=18" } }, - "node_modules/doctrine": { - "version": "3.0.0", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "dev": true, + "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" + ], "license": "MIT", - "peer": true + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/dotenv": { - "version": "10.0.0", - "license": "BSD-2-Clause", + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=10" + "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==", + "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-beta.22", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-1.0.0-beta.22.tgz", + "integrity": "sha512-F+DZyVIvH0oVKa/w08Cle1xfoH+pc+htIXHG/frnMLG72aby9NYYr9oc+9XvghnoO4umxFItduz0OMmQJMnenw==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@effect/sql": "^0.48.5", + "@effect/sql-pg": "^0.49.7", + "@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": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "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": { + "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 + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "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": { @@ -8160,6 +8928,16 @@ "version": "3.0.3", "license": "BSD-3-Clause" }, + "node_modules/fast-write-atomic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/fast-write-atomic/-/fast-write-atomic-0.4.0.tgz", + "integrity": "sha512-O0AGqOsuOgsVXFiF/vIm7c+NHRZB6c6IDMBVPWc8rLGWVW7FYaBBfzhqxKlKyGKVF5tPR4drUOxE02lDiWjbSg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20" + } + }, "node_modules/fast-xml-parser": { "version": "3.19.0", "license": "MIT", @@ -8564,7 +9342,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" @@ -9561,7 +10341,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -9597,6 +10376,12 @@ "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==", + "license": "Apache-2.0" + }, "node_modules/jsesc": { "version": "3.1.0", "dev": true, @@ -11532,6 +12317,102 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "optional": true, + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "optional": true, + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "optional": true, + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "optional": true, + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/phin": { "version": "3.7.1", "license": "MIT", @@ -11736,6 +12617,49 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -13947,7 +14871,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": { @@ -14927,6 +15853,16 @@ "node": ">=4.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "dev": true, @@ -15089,7 +16025,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 +16035,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 02066d3f..c4340471 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@kenyip/backoff-strategies": "^1.0.4", "@keyv/valkey": "^1.0.8", "@lukehagar/plexjs": "^0.39.0", + "@platformatic/job-queue": "^0.5.0", "@supercharge/promise-pool": "^3.0.0", "@svrooij/sonos": "^2.5.0", "@xhayper/discord-rpc": "^1.3.0", @@ -85,6 +86,8 @@ "dbus-ts": "^0.0.7", "discord.js": "^14.26.0", "dotenv": "^10.0.0", + "drizzle-kit": "^1.0.0-beta.22", + "drizzle-orm": "^1.0.0-beta.22", "express": "^5.2.1", "express-session": "^1.19.0", "fast-equals": "^6.0.0", @@ -167,7 +170,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", diff --git a/patches/kysely-node-native-sqlite+1.1.0.patch b/patches/kysely-node-native-sqlite+1.1.0.patch new file mode 100644 index 00000000..2f1a6462 --- /dev/null +++ b/patches/kysely-node-native-sqlite+1.1.0.patch @@ -0,0 +1,60 @@ +diff --git a/node_modules/kysely-node-native-sqlite/dist/index.cjs b/node_modules/kysely-node-native-sqlite/dist/index.cjs +index 1015da7..0679a95 100644 +--- a/node_modules/kysely-node-native-sqlite/dist/index.cjs ++++ b/node_modules/kysely-node-native-sqlite/dist/index.cjs +@@ -55,7 +55,11 @@ var import_node_sqlite = require("node:sqlite"); + var NodeNativeSqliteConnection = class { + #db; + constructor(...args) { +- this.#db = new import_node_sqlite.DatabaseSync(...args); ++ if (args[0] instanceof import_node_sqlite.DatabaseSync) { ++ this.#db = args[0]; ++ } else { ++ this.#db = new import_node_sqlite.DatabaseSync(...args); ++ } + } + [Symbol.dispose]() { + this.#db.close(); +diff --git a/node_modules/kysely-node-native-sqlite/dist/index.d.cts b/node_modules/kysely-node-native-sqlite/dist/index.d.cts +index 72366bb..32e8464 100644 +--- a/node_modules/kysely-node-native-sqlite/dist/index.d.cts ++++ b/node_modules/kysely-node-native-sqlite/dist/index.d.cts +@@ -3,7 +3,7 @@ import { Dialect, SqliteAdapter, Driver, Kysely, DatabaseIntrospector, SqliteQue + + declare class NodeNativeSqliteDialect implements Dialect { + #private; +- constructor(...args: ConstructorParameters); ++ constructor(...args: ConstructorParameters | [DatabaseSync]); + createAdapter(): SqliteAdapter; + createDriver(): Driver; + createIntrospector(db: Kysely): DatabaseIntrospector; +diff --git a/node_modules/kysely-node-native-sqlite/dist/index.d.ts b/node_modules/kysely-node-native-sqlite/dist/index.d.ts +index 72366bb..32e8464 100644 +--- a/node_modules/kysely-node-native-sqlite/dist/index.d.ts ++++ b/node_modules/kysely-node-native-sqlite/dist/index.d.ts +@@ -3,7 +3,7 @@ import { Dialect, SqliteAdapter, Driver, Kysely, DatabaseIntrospector, SqliteQue + + declare class NodeNativeSqliteDialect implements Dialect { + #private; +- constructor(...args: ConstructorParameters); ++ constructor(...args: ConstructorParameters | [DatabaseSync]); + createAdapter(): SqliteAdapter; + createDriver(): Driver; + createIntrospector(db: Kysely): DatabaseIntrospector; +diff --git a/node_modules/kysely-node-native-sqlite/dist/index.js b/node_modules/kysely-node-native-sqlite/dist/index.js +index 8c2b8cf..17b7eb1 100644 +--- a/node_modules/kysely-node-native-sqlite/dist/index.js ++++ b/node_modules/kysely-node-native-sqlite/dist/index.js +@@ -32,7 +32,11 @@ import { DatabaseSync } from "node:sqlite"; + var NodeNativeSqliteConnection = class { + #db; + constructor(...args) { +- this.#db = new DatabaseSync(...args); ++ if (args[0] instanceof DatabaseSync) { ++ this.#db = args[0]; ++ } else { ++ this.#db = new DatabaseSync(...args); ++ } + } + [Symbol.dispose]() { + this.#db.close(); diff --git a/src/backend/common/database/Database.ts b/src/backend/common/database/Database.ts new file mode 100644 index 00000000..9b54a468 --- /dev/null +++ b/src/backend/common/database/Database.ts @@ -0,0 +1,44 @@ +import { DatabaseSync } from 'node:sqlite'; +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'; + +export const MEMORY_DB_NAME = ':memory:'; +export const isMemoryDb = (name: string): boolean => name === MEMORY_DB_NAME; + +export const getDbPath = (name: string = 'ms', workingDirectory?: string): string => { + if(isMemoryDb(name)) { + return MEMORY_DB_NAME; + } + return path.resolve(workingDirectory ?? configDir, `${name}.db`); +} + +export const backupDb = async (dbName: string, parentLogger: Logger = loggerNoop): Promise => { + + const logger = childLogger(parentLogger, 'Migrations'); + + const dbPath = getDbPath(dbName); + 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('Cannot access database path for migrations', {cause: e}); + } + } + + if(dbPath !== MEMORY_DB_NAME && !newDb) { + const backupPath = `${getDbPath(`${Date.now()}-${dbName}`)}.bak`; + logger.info(`Backing up database before migrating => ${backupPath}`); + await fs.copyFile(dbPath, backupPath) + logger.info('Backed up!'); + } +} \ 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 00000000..f2350cc3 --- /dev/null +++ b/src/backend/common/database/drizzle/drizzleUtils.ts @@ -0,0 +1,81 @@ +import { drizzle } from 'drizzle-orm/node-sqlite'; +import { migrate } from 'drizzle-orm/node-sqlite/migrator'; +import { sql as dsl } from 'drizzle-orm'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { getDbPath } from '../Database.js'; +import { fileExists } from '../../../utils/FSUtils.js'; +import { childLogger, Logger } from '@foxxmd/logging'; +import { loggerNoop } from '../../MaybeLogger.js'; +import { projectDir } from '../../index.js'; +import { relations } from './schema/drizzlePlaysTable.js'; + +export async function shouldBackupDb(dbPath: string, parentLogger: Logger = loggerNoop): Promise { + + const logger = childLogger(parentLogger, 'Migrations'); + + logger.info(`Checking database at ${dbPath}`); + if (!fileExists(dbPath)) { + logger.info(`No database exists!`); + return false; + } + + const db = drizzle(dbPath); + + try { + // Ensure the migrations table exists + // https://github.com/drizzle-team/drizzle-orm/issues/1953 + const res = db.all(dsl` + SELECT count(*) FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'; + `); + + if (res[0]['count(*)'] === 0) { + logger.info(`Database exists but there is no __drizzle_migrations table??`); + return false; + } + + const dbMigrations = await db.all(dsl`SELECT id, hash, created_at, name, applied_at FROM "__drizzle_migrations" ORDER BY created_at DESC`); + const appliedMigrations = new Set(dbMigrations.map((m: any) => m.name)); + + const allFiles = await fs.readdir(path.resolve(projectDir, 'src/backend/common/database/drizzleMigrations')); + 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; + } 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 = (dbName: string = 'ms', opts: { logger?: Logger, workingDirectory?: string } = {}) => { + const { + workingDirectory, + logger = loggerNoop + } = opts; + const dbPath = getDbPath(dbName, workingDirectory); + logger.info(`Using database at ${dbPath}`); + return drizzle(dbPath, {relations: relations}); +} + +export const migrateDb = async (db: ReturnType, parentLogger: Logger = loggerNoop) => { + const logger = childLogger(parentLogger, 'Migrations'); + + try { + await migrate(db, { 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 }); + } +} \ No newline at end of file diff --git a/src/backend/common/database/drizzle/migrations/20260422185819_white_wilson_fisk/migration.sql b/src/backend/common/database/drizzle/migrations/20260422185819_white_wilson_fisk/migration.sql new file mode 100644 index 00000000..c467d938 --- /dev/null +++ b/src/backend/common/database/drizzle/migrations/20260422185819_white_wilson_fisk/migration.sql @@ -0,0 +1,35 @@ +CREATE TABLE `play_inputs` ( + `id` integer PRIMARY KEY, + `playId` text(30), + `data` text, + `play` text, + `createdAt` integer, + CONSTRAINT `fk_play_inputs_playId_plays_id_fk` FOREIGN KEY (`playId`) REFERENCES `plays`(`id`) ON UPDATE CASCADE ON DELETE CASCADE +); +--> statement-breakpoint +CREATE TABLE `plays` ( + `id` text(30) PRIMARY KEY, + `componentType` text(50) NOT NULL, + `componentName` text(200) NOT NULL, + `error` text, + `playedAt` integer, + `seenAt` integer, + `play` text NOT NULL, + `parentId` text(30) +); +--> statement-breakpoint +CREATE TABLE `play_queue_state` ( + `id` integer PRIMARY KEY, + `playId` text, + `queueName` text(200), + `queueStatus` text(30), + `error` text, + `createdAt` integer, + CONSTRAINT `fk_play_queue_state_playId_plays_id_fk` FOREIGN KEY (`playId`) REFERENCES `plays`(`id`) ON UPDATE CASCADE ON DELETE CASCADE +); +--> statement-breakpoint +CREATE 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_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_state` (`playId`); \ No newline at end of file diff --git a/src/backend/common/database/drizzle/migrations/20260422185819_white_wilson_fisk/snapshot.json b/src/backend/common/database/drizzle/migrations/20260422185819_white_wilson_fisk/snapshot.json new file mode 100644 index 00000000..cbd6b3ec --- /dev/null +++ b/src/backend/common/database/drizzle/migrations/20260422185819_white_wilson_fisk/snapshot.json @@ -0,0 +1,340 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "96a2050a-7d06-4db1-b989-3082b6374e1f", + "prevIds": [ + "00000000-0000-0000-0000-000000000000" + ], + "ddl": [ + { + "name": "play_inputs", + "entityType": "tables" + }, + { + "name": "plays", + "entityType": "tables" + }, + { + "name": "play_queue_state", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "play_inputs" + }, + { + "type": "text(30)", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "playId", + "entityType": "columns", + "table": "play_inputs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "play_inputs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "play", + "entityType": "columns", + "table": "play_inputs" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "play_inputs" + }, + { + "type": "text(30)", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "plays" + }, + { + "type": "text(50)", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "componentType", + "entityType": "columns", + "table": "plays" + }, + { + "type": "text(200)", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "componentName", + "entityType": "columns", + "table": "plays" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "error", + "entityType": "columns", + "table": "plays" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "playedAt", + "entityType": "columns", + "table": "plays" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seenAt", + "entityType": "columns", + "table": "plays" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "play", + "entityType": "columns", + "table": "plays" + }, + { + "type": "text(30)", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parentId", + "entityType": "columns", + "table": "plays" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "play_queue_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "playId", + "entityType": "columns", + "table": "play_queue_state" + }, + { + "type": "text(200)", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "queueName", + "entityType": "columns", + "table": "play_queue_state" + }, + { + "type": "text(30)", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "queueStatus", + "entityType": "columns", + "table": "play_queue_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "error", + "entityType": "columns", + "table": "play_queue_state" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "play_queue_state" + }, + { + "columns": [ + "playId" + ], + "tableTo": "plays", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_play_inputs_playId_plays_id_fk", + "entityType": "fks", + "table": "play_inputs" + }, + { + "columns": [ + "playId" + ], + "tableTo": "plays", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_play_queue_state_playId_plays_id_fk", + "entityType": "fks", + "table": "play_queue_state" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "play_inputs_pk", + "table": "play_inputs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "plays_pk", + "table": "plays", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "play_queue_state_pk", + "table": "play_queue_state", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "playId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "play_input_id_idx", + "entityType": "indexes", + "table": "play_inputs" + }, + { + "columns": [ + { + "value": "parentId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "play_parent_id_idx", + "entityType": "indexes", + "table": "plays" + }, + { + "columns": [ + { + "value": "playedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "play_playedAt_idx", + "entityType": "indexes", + "table": "plays" + }, + { + "columns": [ + { + "value": "seenAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "play_seenAt_idx", + "entityType": "indexes", + "table": "plays" + }, + { + "columns": [ + { + "value": "playId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "play_queue_state_id_idx", + "entityType": "indexes", + "table": "play_queue_state" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/src/backend/common/database/drizzle/schema/drizzlePlaysTable.ts b/src/backend/common/database/drizzle/schema/drizzlePlaysTable.ts new file mode 100644 index 00000000..6d3fc815 --- /dev/null +++ b/src/backend/common/database/drizzle/schema/drizzlePlaysTable.ts @@ -0,0 +1,117 @@ +import { integer, sqliteTable, text, index, customType } from "drizzle-orm/sqlite-core"; +import { defineRelations } from 'drizzle-orm'; +import dayjs, { Dayjs } from "dayjs"; + +const DayjsTimestamp = customType< + { + data: Dayjs; + driverData: number; + } +>({ + dataType() { + return 'number' + }, + toDriver(value: Dayjs): number { + return value.valueOf(); + }, + fromDriver(value: number): Dayjs { + return dayjs(value); + }, +}); + +export const plays = sqliteTable("plays", { + id: text({ length: 30 }).primaryKey(), + componentType: text({ length: 50 }).notNull(), + componentName: text({ length: 200 }).notNull(), + error: text({ mode: 'json' }), + playedAt: DayjsTimestamp('playedAt'), // integer({ mode: 'timestamp_ms' }), + seenAt: DayjsTimestamp('seenAt'), // integer({ mode: 'timestamp_ms' }), + play: text({ mode: 'json' }).notNull(), + // TODO can this have a reference with cascade? + parentId: text({ length: 30 }) +}, (table) => [ + index("play_parent_id_idx").on(table.parentId), + index("play_playedAt_idx").on(table.playedAt), + index("play_seenAt_idx").on(table.seenAt), +]); + +export type NewPlay = typeof plays.$inferInsert; +export type SelectPlay = typeof plays.$inferSelect; + +export const playInputs = sqliteTable("play_inputs", { + id: integer({ mode: 'number' }).primaryKey(), + playId: text({ length: 30 }).references(() => plays.id, {onDelete: 'cascade', onUpdate: 'cascade'}), + data: text({ mode: 'json' }), + play: text({ mode: 'json' }), + createdAt: DayjsTimestamp('createdAt').$defaultFn(() => dayjs()) // integer({ mode: 'timestamp_ms' }) +}, (table) => [ + index('play_input_id_idx').on(table.playId) +]); + +// export const playParentRelations = defineRelations({plays}, (r) => ({ +// plays: { +// parent: r.one.plays({ +// from: r.plays.parentId, +// to: r.plays.id +// }), +// children: r.many.plays() +// } +// })) + + +// export const playInputRelations = defineRelations({ plays, playInputs }, (r) => ({ +// plays: { +// input: r.one.playInputs({ +// from: r.plays.id, +// to: r.playInputs.playId, +// optional: false, +// }) +// } +// })); + +export const queueStates = sqliteTable("play_queue_state", { + id: integer({ mode: 'number' }).primaryKey(), + playId: text().references(() => plays.id, {onDelete: 'cascade', onUpdate: 'cascade'}), + queueName: text({length: 200}), + queueStatus: text({length: 30}), + error: text({ mode: 'json' }), + createdAt: DayjsTimestamp('createdAt').$defaultFn(() => dayjs()) // integer({ mode: 'timestamp_ms' }) +}, (table) => [ + index('play_queue_state_id_idx').on(table.playId) +]); + +// export const playQueueRelations = defineRelations({ plays, queueStates }, (r) => ({ +// plays: { +// queueStates: r.many.queueStates() +// }, +// queueStates: { +// play: r.one.plays({ +// from: r.queueStates.playId, +// to: r.plays.id +// }) +// } +// })); + +export const playRelations = defineRelations({ plays, queueStates, playInputs }, (r) => ({ + plays: { + queueStates: r.many.queueStates(), + input: r.one.playInputs({ + from: r.plays.id, + to: r.playInputs.playId, + optional: false, + }), + parent: r.one.plays({ + from: r.plays.parentId, + to: r.plays.id + }), + children: r.many.plays() + }, + queueStates: { + play: r.one.plays({ + from: r.queueStates.playId, + to: r.plays.id + }) + } +})); + +export const relations = playRelations; \ No newline at end of file diff --git a/src/backend/common/errors/MSErrors.ts b/src/backend/common/errors/MSErrors.ts index dcfdc382..4b15cae4 100644 --- a/src/backend/common/errors/MSErrors.ts +++ b/src/backend/common/errors/MSErrors.ts @@ -43,12 +43,14 @@ export class SimpleError extends Error implements HasSimpleError { stackShortened: boolean = false; shortenStack() { - const atIndex = parseRegexSingle(STACK_AT_REGEX,this.stack); - if(atIndex !== undefined) { - const firstn = this.stack.indexOf('\n', atIndex.index + atIndex.match.length); - if(firstn !== -1) { - this.stack = this.stack.slice(0, firstn); - this.stackShortened = true; + if(this.stack !== undefined) { + const atIndex = parseRegexSingle(STACK_AT_REGEX, this.stack); + if(atIndex !== undefined) { + const firstn = this.stack.indexOf('\n', atIndex.index + atIndex.match.length); + if(firstn !== -1) { + this.stack = this.stack.slice(0, firstn); + this.stackShortened = true; + } } } } diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index fdb39c70..fdb4cca2 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -68,12 +68,14 @@ import { generateLoggableAbortReason, ScrobbleSubmitError, SimpleError } from ". import {serializeError} from 'serialize-error'; import { DEFAULT_NEW_PADDING, groupPlaysToTimeRanges } from "../utils/ListenFetchUtils.js"; import { spawn, catchAbortError, isAbortError, rethrowAbortError, delay, forever, AbortError, throwIfAborted } from 'abort-controller-x'; +import { Queue, MemoryStorage } from '@platformatic/job-queue' type PlatformMappedPlays = Map; type NowPlayingQueue = Map; const platformTruncate = truncateStringToLength(10); + export default abstract class AbstractScrobbleClient extends AbstractComponent implements Authenticatable { name: string; @@ -116,6 +118,8 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i dupeLogger: Logger; deadLogger: Logger; + scrobbleQueue: Queue<{payload: QueuedScrobble}, {scrobbled: ScrobbledPlayObject}>; + existingScrobble: (playObjPre: PlayObject, existingScrobbles: PlayObject[], log?: boolean) => Promise declare config: CommonClientConfig; @@ -145,6 +149,10 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i this.notifier = notifier; this.emitter = emitter; this.scrobbledPlayObjs = new FixedSizeList(this.MAX_STORED_SCROBBLES); + this.scrobbleQueue = new Queue<{payload: QueuedScrobble}, {scrobbled: ScrobbledPlayObject}>({ + storage: new MemoryStorage(), + concurrency: 1 + }); const { options: { diff --git a/src/backend/tests/database/drizzle.test.ts b/src/backend/tests/database/drizzle.test.ts new file mode 100644 index 00000000..c22760de --- /dev/null +++ b/src/backend/tests/database/drizzle.test.ts @@ -0,0 +1,94 @@ +import chai, { assert, expect } from 'chai'; +import asPromised from 'chai-as-promised'; +import { getDb, migrateDb, shouldBackupDb } from '../../common/database/drizzle/drizzleUtils.js'; +import withLocalTmpDir from 'with-local-tmp-dir'; +import { playInputs, plays, queueStates } from '../../common/database/drizzle/schema/drizzlePlaysTable.js'; +import { nanoid } from 'nanoid'; +import dayjs from 'dayjs'; +import { generatePlay } from '../../../core/PlayTestUtils.js'; +import { getDbPath } from '../../common/database/Database.js'; + +// it('Detects pending migrations', async function () { + +// const res = await shouldBackupDb(getDbPath(undefined, process.cwd())); + +// expect(res).to.be.undefined; + +// }); + +describe('Basic DB Operations', function () { + + it('Should create a play', async function () { + + withLocalTmpDir(async () => { + + const db = getDb(':memory:', { workingDirectory: process.cwd() }); + await migrateDb(db); + + const playRow = await db.insert(plays).values({ + id: nanoid(), + componentName: 'mySpot', + componentType: 'spotify', + playedAt: dayjs(), + seenAt: dayjs(), + play: generatePlay() + }); + + expect(playRow.changes).eq(1); + + }, { unsafeCleanup: true }); + }); + + it('Should create a play with relations', async function () { + + withLocalTmpDir(async () => { + + const db = getDb(':memory:', { workingDirectory: process.cwd() }); + await migrateDb(db); + + const id = nanoid(); + const playRow = await db.insert(plays).values({ + id, + componentName: 'mySpot', + componentType: 'spotify', + playedAt: dayjs(), + seenAt: dayjs(), + play: generatePlay() + }).returning(); + + const input = await db.insert(playInputs).values({ + playId: playRow[0].id, + play: playRow[0].play, + data: { anything: 'foo' } + }).returning(); + + const twoQueues = await db.insert(queueStates).values([ + { + playId: id, + queueName: 'foo', + queueStatus: 'queued' + }, + { + playId: id, + queueName: 'bar', + queueStatus: 'completed' + } + ]); + + const fullPlay = await db.query.plays.findFirst({ + with: { + input: true, + queueStates: true, + }, + }); + + expect(fullPlay.queueStates).to.not.be.undefined; + expect(fullPlay.queueStates).length(2); + + expect(fullPlay.input).to.not.be.undefined; + + }, { unsafeCleanup: true }); + }); + +}); + diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index a49d17a0..ec711556 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -20,7 +20,7 @@ import { PaginatedTimeRangeOptions, PlayPlatformId, REFRESH_STALE_DEFAULT } from import { defaultLifecycle } from '../../utils/PlayTransformUtils.js'; import { shuffleArray } from '../../utils/DataUtils.js'; import { DEFAULT_CONSOLIDATE_DURATION, DEFAULT_GROUP_DURATION, groupPlaysToTimeRanges } from '../../utils/ListenFetchUtils.js'; -import { asPlay } from '../../../core/tests/utils/fixtures.js'; +import { asPlay } from '../../../core/PlayMarshalUtils.js'; import { nanoid } from 'nanoid'; import { getRoot } from '../../ioc.js'; import { transientCache } from '../utils/CacheTestUtils.js'; diff --git a/src/backend/utils/FSUtils.ts b/src/backend/utils/FSUtils.ts index dca0d517..8ce631e4 100644 --- a/src/backend/utils/FSUtils.ts +++ b/src/backend/utils/FSUtils.ts @@ -33,6 +33,7 @@ export async function readText(path: any) { // }); // }); } + export const fileOrDirectoryIsWriteable = (location: string) => { const pathInfo = pathUtil.parse(location); const isDir = pathInfo.ext === ''; @@ -63,3 +64,20 @@ export const fileOrDirectoryIsWriteable = (location: string) => { } }; +export const fileExists = (location: string) => { + const pathInfo = pathUtil.parse(location); + const isDir = pathInfo.ext === ''; + try { + accessSync(location, constants.R_OK); + return true; + } catch (err: any) { + const { code } = err; + if (code === 'ENOENT') { + return false; + } else if (code === 'EACCES') { + throw new Error(`${isDir ? 'Directory' : 'File'} exists at ${location} but application does not have permission to write to it.`); + } else { + throw new Error(`${isDir ? 'Directory' : 'File'} exists at ${location} but application is unable to access it due to a system error`, { cause: err }); + } + } +}; \ No newline at end of file diff --git a/src/core/PlayMarshalUtils.ts b/src/core/PlayMarshalUtils.ts new file mode 100644 index 00000000..420a47a1 --- /dev/null +++ b/src/core/PlayMarshalUtils.ts @@ -0,0 +1,82 @@ +import clone from 'clone'; +import dayjs from 'dayjs'; +import { Traverse, TraverseContext } from 'neotraverse/modern'; +import { ListenRange } from '../backend/sources/PlayerState/ListenRange.js'; +import { AmbPlayObject, JsonPlayObject, PlayObject, PlayProgressAmb, REGEX_ISO8601_LOOSE } from './Atomic.js'; +import { ListenProgressPositional, ListenProgressTS } from '../backend/sources/PlayerState/ListenProgress.js'; + +interface BlockPath { key: string, parent: string }; +type BlockPaths = BlockPath[]; + +/** We know some nodes will never have data that needs to be transformed + * and these nodes can have lots of data so we can optimize them away by not (recursively) traversing them + */ +const blockedKeys: PropertyKey[] = ['patch', 'inputs', 'payload', 'response', 'error']; +/** We know some paths/nodes will never have data that needs to be transformed + * and these nodes can have lots of data so we can optimize them away by not (recursively) traversing them + */ +const blockedPaths: BlockPaths = [ + { + parent: 'data', + key: 'meta' + }, + { + parent: 'lifecycle', + key: 'input' + } +]; + +export const shouldBlock = (ctx: TraverseContext): boolean => { + if (blockedKeys.includes(ctx.key)) { + return true; + } + return blockedPaths.some((x) => { + let blocked = x.key === ctx.key; + if (blocked && x.parent !== undefined) { + blocked = ctx.parent !== undefined && ctx.parent.key === x.parent; + } + return blocked; + }); +}; + +export const asJsonPlayObject = (play: AmbPlayObject): JsonPlayObject => { + const cloned = clone(play); + new Traverse(cloned).forEach((ctx, x) => { + if (shouldBlock(ctx)) { + ctx.block(); + return; + } + + if (dayjs.isDayjs(x)) { + ctx.update(x.toISOString()); + } else if (x instanceof ListenRange) { + ctx.update(x.toJSON(), true); + } + }); + return cloned as unknown as JsonPlayObject; +}; + +export const asPlay = (data: JsonPlayObject): PlayObject => { + const cloned = clone(data); + new Traverse(cloned).forEach((ctx, x) => { + if (shouldBlock(ctx)) { + ctx.block(); + return; + } + + if (typeof x === 'string' && REGEX_ISO8601_LOOSE.test(x)) { + ctx.update(dayjs(x), true); + } else if (ctx.key === 'listenRanges') { + const ranges = x[0].map((y: PlayProgressAmb) => { + if (y.positionPercent === undefined) { + return new ListenProgressPositional({ timestamp: dayjs(y.timestamp), position: y.position }); + } else { + return new ListenProgressTS({ timestamp: dayjs(y.timestamp), positionPercent: y.positionPercent }); + } + }); + ctx.update(ranges, true); + } + }); + return cloned as unknown as PlayObject; +}; + diff --git a/src/core/tests/utils/fixtures.ts b/src/core/tests/utils/fixtures.ts index 6ec28152..bd21c5ea 100644 --- a/src/core/tests/utils/fixtures.ts +++ b/src/core/tests/utils/fixtures.ts @@ -1,9 +1,6 @@ import { Traverse, TraverseContext } from 'neotraverse/modern'; import { faker } from '@faker-js/faker'; -import dayjs from 'dayjs'; -import { AmbPlayObject, JsonPlayObject, LifecycleInput, LifecycleStep, ObjectPlayData, PlayMeta, PlayObject, PlayProgressAmb, REGEX_ISO8601_LOOSE, ScrobbleResult } from '../../Atomic.js'; -import { ListenRange } from '../../../backend/sources/PlayerState/ListenRange.js'; -import { ListenProgressPositional, ListenProgressTS } from '../../../backend/sources/PlayerState/ListenProgress.js'; +import { LifecycleInput, LifecycleStep, ObjectPlayData, PlayMeta, PlayObject, ScrobbleResult } from '../../Atomic.js'; import { MarkOptional } from 'ts-essentials'; import { generateBrainz, generateMbid, generatePlay, GeneratePlayOpts, generatePlays } from '../../PlayTestUtils.js'; import { lifecyclelessInvariantTransform } from '../../PlayUtils.js'; @@ -14,81 +11,6 @@ import { UpstreamError } from '../../../backend/common/errors/UpstreamError.js'; import { playToListenPayload } from '../../../backend/common/vendor/listenbrainz/lzUtils.js'; import { mergeSimpleError, SimpleError, SkipTransformStageError, StagePrerequisiteError } from '../../../backend/common/errors/MSErrors.js'; -interface BlockPath { key: string, parent: string }; -type BlockPaths = BlockPath[]; - -/** We know some nodes will never have data that needs to be transformed - * and these nodes can have lots of data so we can optimize them away by not (recursively) traversing them - */ -const blockedKeys: PropertyKey[] = ['patch', 'inputs', 'payload', 'response', 'error']; -/** We know some paths/nodes will never have data that needs to be transformed - * and these nodes can have lots of data so we can optimize them away by not (recursively) traversing them - */ -const blockedPaths: BlockPaths = [ - { - parent: 'data', - key: 'meta' - }, - { - parent: 'lifecycle', - key: 'input' - } -] - -const shouldBlock = (ctx: TraverseContext): boolean => { - if (blockedKeys.includes(ctx.key)) { - return true; - } - return blockedPaths.some((x) => { - let blocked = x.key === ctx.key; - if (blocked && x.parent !== undefined) { - blocked = ctx.parent !== undefined && ctx.parent.key === x.parent; - } - return blocked; - }) -} - -export const asJsonPlayObject = (play: AmbPlayObject): JsonPlayObject => { - const cloned = clone(play); - new Traverse(cloned).forEach((ctx, x) => { - if (shouldBlock(ctx)) { - ctx.block(); - return; - } - - if (dayjs.isDayjs(x)) { - ctx.update(x.toISOString()); - } else if (x instanceof ListenRange) { - ctx.update(x.toJSON(), true); - } - }); - return cloned as unknown as JsonPlayObject; -} - -export const asPlay = (data: JsonPlayObject): PlayObject => { - const cloned = clone(data); - new Traverse(cloned).forEach((ctx, x) => { - if (shouldBlock(ctx)) { - ctx.block(); - return; - } - - if (typeof x === 'string' && REGEX_ISO8601_LOOSE.test(x)) { - ctx.update(dayjs(x), true); - } else if (ctx.key === 'listenRanges') { - const ranges = x[0].map((y: PlayProgressAmb) => { - if (y.positionPercent === undefined) { - return new ListenProgressPositional({ timestamp: dayjs(y.timestamp), position: y.position }); - } else { - return new ListenProgressTS({ timestamp: dayjs(y.timestamp), positionPercent: y.positionPercent }); - } - }) - ctx.update(ranges, true); - } - }); - return cloned as unknown as PlayObject; -} - export interface ScrobbleMatchOptions { match?: boolean warnings?: boolean diff --git a/src/stories/ActivityTimeline.stories.tsx b/src/stories/ActivityTimeline.stories.tsx index 7f3a0020..185b23b8 100644 --- a/src/stories/ActivityTimeline.stories.tsx +++ b/src/stories/ActivityTimeline.stories.tsx @@ -8,7 +8,8 @@ import {Provider} from "../client/components/Provider"; import { generateJsonPlays } from "../core/PlayTestUtils.js"; import { ErrorLike, JsonPlayObject, PlayLifecycle } from "../core/Atomic.js"; import { examplePlay, lastfmErrorExample } from "./storyUtils.js"; -import { asJsonPlayObject, generatePlayWithLifecycle, playWithLifecycleScrobble } from "../core/tests/utils/fixtures.js"; +import { generatePlayWithLifecycle, playWithLifecycleScrobble } from "../core/tests/utils/fixtures.js"; +import { asJsonPlayObject } from '../core/PlayMarshalUtils.js'; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = preview.meta({ diff --git a/src/stories/List.stories.tsx b/src/stories/List.stories.tsx index 0f3e1086..f13ea231 100644 --- a/src/stories/List.stories.tsx +++ b/src/stories/List.stories.tsx @@ -8,9 +8,10 @@ import {Provider} from "../client/components/Provider"; import { generateJsonPlays, normalizePlays } from "../core/PlayTestUtils.js"; import { ErrorLike, JsonPlayObject } from "../core/Atomic.js"; import {examplePlay, lastfmErrorExample} from './storyUtils.js'; -import {playWithLifecycleScrobble, generatePlayWithLifecycle, asJsonPlayObject} from '../core/tests/utils/fixtures' +import {playWithLifecycleScrobble, generatePlayWithLifecycle} from '../core/tests/utils/fixtures' import { generateArray } from "../core/DataUtils.js"; import dayjs from "dayjs"; +import { asJsonPlayObject } from "../core/PlayMarshalUtils.js"; const stack = "Scrobble Submit Error: Failed to submit to Listenbrainz (listen_type single)\n at ListenbrainzApiClient.submitListen (/app/src/backend/common/vendor/ListenbrainzApiClient.ts:246:19)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async ListenbrainzScrobbler.doScrobble (/app/src/backend/scrobblers/ListenbrainzScrobbler.ts:87:28)\n at async ListenbrainzScrobbler.scrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:679:28)\n at async ListenbrainzScrobbler.processDeadLetterScrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:920:39)\n at async ListenbrainzScrobbler.processDeadLetterQueue (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:894:43)\n at async PromisePoolExecutor.handler (/app/src/backend/tasks/heartbeatClients.ts:35:21)\n at async PromisePoolExecutor.waitForActiveTaskToFinish (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:375:9)\n at async PromisePoolExecutor.waitForProcessingSlot (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:368:13)\n at async PromisePoolExecutor.process (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:354:13)"; diff --git a/src/stories/PlayInfo.stories.tsx b/src/stories/PlayInfo.stories.tsx index fde02b31..d38f88c9 100644 --- a/src/stories/PlayInfo.stories.tsx +++ b/src/stories/PlayInfo.stories.tsx @@ -7,7 +7,7 @@ import {Provider} from "../client/components/Provider"; import { Container } from '@chakra-ui/react'; import { generateArtists, generateJsonPlay, generatePlay, withBrainz } from "../core/PlayTestUtils.js" import clone from "clone"; -import { asJsonPlayObject } from "../core/tests/utils/fixtures.js"; +import { asJsonPlayObject } from '../core/PlayMarshalUtils.js'; type PropsAndCustomArgs = React.ComponentProps & { includeAlbumArtists?: boolean; diff --git a/src/stories/TransformSteps.stories.tsx b/src/stories/TransformSteps.stories.tsx index b0c989a0..aafa70e3 100644 --- a/src/stories/TransformSteps.stories.tsx +++ b/src/stories/TransformSteps.stories.tsx @@ -8,7 +8,8 @@ import {Provider} from "../client/components/Provider"; import { generateJsonPlays } from "../core/PlayTestUtils.js"; import { ErrorLike, JsonPlayObject, PlayLifecycle } from "../core/Atomic.js"; import { examplePlay, lastfmErrorExample } from "./storyUtils.js"; -import {asJsonPlayObject, generatePlayWithLifecycle} from '../core/tests/utils/fixtures' +import {generatePlayWithLifecycle} from '../core/tests/utils/fixtures' +import { asJsonPlayObject } from "../core/PlayMarshalUtils.js"; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = preview.meta({ diff --git a/tsconfig.json b/tsconfig.json index 109f70a5..4c6cfbc0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "sourceMap": false, }, "include": [ - "src" + "src", + "drizzle.config.ts" ], }