Compare commits

...

28 Commits

Author SHA1 Message Date
advplyr
1f37d4a7d5 Update MigrationManager unit test 2024-09-13 14:22:45 -05:00
advplyr
2f83e86d69 Set intitial database version in migrationsMeta to 0.0.0 2024-09-13 14:14:51 -05:00
advplyr
5c49a8ce6a Merge pull request #3407 from agraubert/patch-1
Default deny explicit content to users
2024-09-13 13:24:12 -05:00
advplyr
854f308eae Merge pull request #3410 from mikiher/library-scan-try-catch
Handle library scan failure gracefully
2024-09-13 13:10:46 -05:00
advplyr
16ba6b53ba Merge pull request #3414 from thatguy7/master
Improved handling of Authors and Series with names containing non-ASCII characters
2024-09-13 12:59:01 -05:00
Oleg Ivasenko
0af29a378a use asciiOnlyToLowerCase to match lower function behaviour of SQLite 2024-09-13 17:09:32 +00:00
Oleg Ivasenko
def34a860b when checking if series/author is alread in DB, use case insensitive match only for ASCII names 2024-09-13 16:23:25 +00:00
mikiher
f8034e1b78 scanLibrary fail and cancel handling round 2 2024-09-13 09:23:48 +03:00
advplyr
01fbea02f1 Clean out old unused functions, Device updates for replacing DeviceInfo 2024-09-12 16:36:39 -05:00
advplyr
3d9af89e24 Merge pull request #3411 from justcallmelarry/feature/add-duration-when-creating-sessions
Add duration to local sessions on creation
2024-09-12 15:23:10 -05:00
Lauri Vuorela
d430d9f3ed add new setDuration and use that 2024-09-12 20:05:08 +02:00
Lauri Vuorela
0c24a1e626 add duration to session when creating 2024-09-12 19:46:08 +02:00
mikiher
1099dbe642 Handle library scan failure gracefully 2024-09-12 18:56:52 +03:00
Aaron Graubert
2df3277dcd Server side change to enable default explicit acces for admins 2024-09-11 23:09:04 -06:00
Aaron Graubert
6ae14213f5 Related ui changes for removing default explicit access 2024-09-11 23:08:00 -06:00
Aaron Graubert
61bd029303 Default deny explicit content to users 2024-09-11 22:42:21 -06:00
advplyr
5b09bd8242 Merge pull request #3374 from wommy/update-nuxt-2.18.1
client update: nuxt 2.17.3 -> 2.18.1
2024-09-11 16:28:08 -05:00
advplyr
703477b157 Merge pull request #3405 from mikiher/logger-fixes
Log non-strings into log file like console.log does
2024-09-11 14:31:05 -05:00
mikiher
03ff5d8ae1 Disregard socketListener.level if level >= FATAL 2024-09-11 22:05:38 +03:00
mikiher
220f7ef7cd Resolve some weird unrelated flakiness in BookFinder test 2024-09-11 21:40:31 +03:00
mikiher
682a99dd43 Log non-strings into log file like console.log does 2024-09-11 19:58:30 +03:00
advplyr
fac5de582d Merge pull request #3378 from mikiher/migration-manager
Add db migration management infratructure
2024-09-10 16:50:39 -05:00
advplyr
7cbf9de8ca Update migrations jsdocs 2024-09-10 15:57:07 -05:00
mikiher
6fb1202c1c Put umzug in server/libs and remove unneeded dependencies from it 2024-09-08 21:33:32 +03:00
mikiher
8a28029809 Make migration management more robust 2024-09-07 22:24:19 +03:00
mikiher
b3ce300d32 Fix some packaging and dependency issues 2024-09-04 23:55:16 +03:00
mikiher
3f93b93d9e Add db migration management infratructure 2024-09-04 12:48:10 +03:00
wommy
e32c83db63 npm update nuxt: 2.17.3 -> 2.18.1 2024-09-03 19:36:58 -04:00
41 changed files with 5495 additions and 2556 deletions

View File

@@ -351,7 +351,7 @@ export default {
update: type === 'admin',
delete: type === 'admin',
upload: type === 'admin',
accessExplicitContent: true,
accessExplicitContent: type === 'admin',
accessAllLibraries: true,
accessAllTags: true,
selectedTagsNotAccessible: false
@@ -386,7 +386,7 @@ export default {
upload: false,
accessAllLibraries: true,
accessAllTags: true,
accessExplicitContent: true,
accessExplicitContent: false,
selectedTagsNotAccessible: false
},
librariesAccessible: [],

5122
client/package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,7 @@
"fast-average-color": "^9.4.0",
"hls.js": "^1.5.7",
"libarchive.js": "^1.3.0",
"nuxt": "^2.17.3",
"nuxt": "^2.18.1",
"nuxt-socket-io": "^1.1.18",
"trix": "^1.3.1",
"v-click-outside": "^3.1.2",

201
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"p-throttle": "^4.1.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"semver": "^7.6.3",
"sequelize": "^6.35.2",
"socket.io": "^4.5.4",
"sqlite3": "^5.1.6",
@@ -173,6 +174,15 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/generator": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz",
@@ -213,6 +223,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -586,17 +605,6 @@
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@@ -611,20 +619,6 @@
"node": ">=6"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/semver": {
"version": "7.5.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
"integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@npmcli/fs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
@@ -635,33 +629,6 @@
"semver": "^7.3.5"
}
},
"node_modules/@npmcli/fs/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@npmcli/fs/node_modules/semver": {
"version": "7.5.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
"integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==",
"optional": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@npmcli/move-file": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz",
@@ -2576,6 +2543,15 @@
"node": ">=8"
}
},
"node_modules/istanbul-lib-instrument/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/istanbul-lib-processinfo": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz",
@@ -2628,18 +2604,6 @@
"node": ">=8"
}
},
"node_modules/istanbul-lib-report/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-report/node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@@ -2655,21 +2619,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/istanbul-lib-report/node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-report/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -2804,36 +2753,11 @@
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/just-extend": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz",
@@ -2970,6 +2894,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/make-fetch-happen": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz",
@@ -3585,18 +3517,6 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/node-gyp/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-gyp/node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@@ -3627,21 +3547,6 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/node-gyp/node_modules/semver": {
"version": "7.5.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
"integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==",
"optional": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-preload": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz",
@@ -4336,11 +4241,14 @@
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
@@ -4456,36 +4364,11 @@
}
}
},
"node_modules/sequelize/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sequelize/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/sequelize/node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/serialize-javascript": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",

View File

@@ -47,6 +47,7 @@
"p-throttle": "^4.1.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"semver": "^7.6.3",
"sequelize": "^6.35.2",
"socket.io": "^4.5.4",
"sqlite3": "^5.1.6",

View File

@@ -8,6 +8,8 @@ const Logger = require('./Logger')
const dbMigration = require('./utils/migrations/dbMigration')
const Auth = require('./Auth')
const MigrationManager = require('./managers/MigrationManager')
class Database {
constructor() {
this.sequelize = null
@@ -142,6 +144,11 @@ class Database {
return this.models.mediaItemShare
}
/** @type {typeof import('./models/Device')} */
get deviceModel() {
return this.models.device
}
/**
* Check if db file exists
* @returns {boolean}
@@ -168,6 +175,15 @@ class Database {
throw new Error('Database connection failed')
}
try {
const migrationManager = new MigrationManager(this.sequelize, global.ConfigPath)
await migrationManager.init(packageJson.version)
if (!this.isNew) await migrationManager.runMigrations()
} catch (error) {
Logger.error(`[Database] Failed to run migrations`, error)
throw new Error('Database migration failed')
}
await this.buildModels(force)
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
@@ -478,21 +494,6 @@ class Database {
return this.models.playbackSession.removeById(sessionId)
}
getDeviceByDeviceId(deviceId) {
if (!this.sequelize) return false
return this.models.device.getOldDeviceByDeviceId(deviceId)
}
updateDevice(oldDevice) {
if (!this.sequelize) return false
return this.models.device.updateFromOld(oldDevice)
}
createDevice(oldDevice) {
if (!this.sequelize) return false
return this.models.device.createFromOld(oldDevice)
}
replaceTagInFilterData(oldTag, newTag) {
for (const libraryId in this.libraryFilterData) {
const indexOf = this.libraryFilterData[libraryId].tags.findIndex((n) => n === oldTag)

View File

@@ -1,5 +1,6 @@
const date = require('./libs/dateAndTime')
const { LogLevel } = require('./utils/constants')
const util = require('util')
class Logger {
constructor() {
@@ -69,27 +70,29 @@ class Logger {
/**
*
* @param {number} level
* @param {string} levelName
* @param {string[]} args
* @param {string} src
*/
async handleLog(level, args, src) {
async #logToFileAndListeners(level, levelName, args, src) {
const expandedArgs = args.map((arg) => (typeof arg !== 'string' ? util.inspect(arg) : arg))
const logObj = {
timestamp: this.timestamp,
source: src,
message: args.join(' '),
levelName: this.getLogLevelString(level),
message: expandedArgs.join(' '),
levelName,
level
}
// Emit log to sockets that are listening to log events
this.socketListeners.forEach((socketListener) => {
if (socketListener.level <= level) {
if (level >= LogLevel.FATAL || level >= socketListener.level) {
socketListener.socket.emit('log', logObj)
}
})
// Save log to file
if (level >= this.logLevel) {
if (level >= LogLevel.FATAL || level >= this.logLevel) {
await this.logManager?.logToFile(logObj)
}
}
@@ -99,50 +102,50 @@ class Logger {
this.debug(`Set Log Level to ${this.levelString}`)
}
static ConsoleMethods = {
TRACE: 'trace',
DEBUG: 'debug',
INFO: 'info',
WARN: 'warn',
ERROR: 'error',
FATAL: 'error',
NOTE: 'log'
}
#log(levelName, source, ...args) {
const level = LogLevel[levelName]
if (level < LogLevel.FATAL && level < this.logLevel) return
const consoleMethod = Logger.ConsoleMethods[levelName]
console[consoleMethod](`[${this.timestamp}] ${levelName}:`, ...args)
this.#logToFileAndListeners(level, levelName, args, source)
}
trace(...args) {
if (this.logLevel > LogLevel.TRACE) return
console.trace(`[${this.timestamp}] TRACE:`, ...args)
this.handleLog(LogLevel.TRACE, args, this.source)
this.#log('TRACE', this.source, ...args)
}
debug(...args) {
if (this.logLevel > LogLevel.DEBUG) return
console.debug(`[${this.timestamp}] DEBUG:`, ...args, `(${this.source})`)
this.handleLog(LogLevel.DEBUG, args, this.source)
this.#log('DEBUG', this.source, ...args)
}
info(...args) {
if (this.logLevel > LogLevel.INFO) return
console.info(`[${this.timestamp}] INFO:`, ...args)
this.handleLog(LogLevel.INFO, args, this.source)
this.#log('INFO', this.source, ...args)
}
warn(...args) {
if (this.logLevel > LogLevel.WARN) return
console.warn(`[${this.timestamp}] WARN:`, ...args, `(${this.source})`)
this.handleLog(LogLevel.WARN, args, this.source)
this.#log('WARN', this.source, ...args)
}
error(...args) {
if (this.logLevel > LogLevel.ERROR) return
console.error(`[${this.timestamp}] ERROR:`, ...args, `(${this.source})`)
this.handleLog(LogLevel.ERROR, args, this.source)
this.#log('ERROR', this.source, ...args)
}
/**
* Fatal errors are ones that exit the process
* Fatal logs are saved to crash_logs.txt
*
* @param {...any} args
*/
fatal(...args) {
console.error(`[${this.timestamp}] FATAL:`, ...args, `(${this.source})`)
return this.handleLog(LogLevel.FATAL, args, this.source)
this.#log('FATAL', this.source, ...args)
}
note(...args) {
console.log(`[${this.timestamp}] NOTE:`, ...args)
this.handleLog(LogLevel.NOTE, args, this.source)
this.#log('NOTE', this.source, ...args)
}
}
module.exports = new Logger()

21
server/libs/umzug/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014-2017 Sequelize contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,31 @@
'use strict'
var __createBinding =
(this && this.__createBinding) ||
(Object.create
? function (o, m, k, k2) {
if (k2 === undefined) k2 = k
var desc = Object.getOwnPropertyDescriptor(m, k)
if (!desc || ('get' in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = {
enumerable: true,
get: function () {
return m[k]
}
}
}
Object.defineProperty(o, k2, desc)
}
: function (o, m, k, k2) {
if (k2 === undefined) k2 = k
o[k2] = m[k]
})
var __exportStar =
(this && this.__exportStar) ||
function (m, exports) {
for (var p in m) if (p !== 'default' && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p)
}
Object.defineProperty(exports, '__esModule', { value: true })
__exportStar(require('./umzug'), exports)
__exportStar(require('./storage'), exports)
__exportStar(require('./types'), exports)
//# sourceMappingURL=index.js.map

View File

@@ -0,0 +1,18 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.verifyUmzugStorage = exports.isUmzugStorage = void 0;
function isUmzugStorage(arg) {
return (arg &&
typeof arg.logMigration === 'function' &&
typeof arg.unlogMigration === 'function' &&
typeof arg.executed === 'function');
}
exports.isUmzugStorage = isUmzugStorage;
const verifyUmzugStorage = (arg) => {
if (!isUmzugStorage(arg)) {
throw new Error(`Invalid umzug storage`);
}
return arg;
};
exports.verifyUmzugStorage = verifyUmzugStorage;
//# sourceMappingURL=contract.js.map

View File

@@ -0,0 +1,24 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
// codegen:start {preset: barrel}
__exportStar(require("./contract"), exports);
__exportStar(require("./json"), exports);
__exportStar(require("./memory"), exports);
__exportStar(require("./mongodb"), exports);
__exportStar(require("./sequelize"), exports);
// codegen:end
//# sourceMappingURL=index.js.map

View File

@@ -0,0 +1,61 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.JSONStorage = void 0;
const fs_1 = require("fs");
const path = __importStar(require("path"));
const filesystem = {
/** reads a file as a string or returns null if file doesn't exist */
async readAsync(filepath) {
return fs_1.promises.readFile(filepath).then(c => c.toString(), () => null);
},
/** writes a string as file contents, creating its parent directory if necessary */
async writeAsync(filepath, content) {
await fs_1.promises.mkdir(path.dirname(filepath), { recursive: true });
await fs_1.promises.writeFile(filepath, content);
},
};
class JSONStorage {
constructor(options) {
var _a;
this.path = (_a = options === null || options === void 0 ? void 0 : options.path) !== null && _a !== void 0 ? _a : path.join(process.cwd(), 'umzug.json');
}
async logMigration({ name: migrationName }) {
const loggedMigrations = await this.executed();
loggedMigrations.push(migrationName);
await filesystem.writeAsync(this.path, JSON.stringify(loggedMigrations, null, 2));
}
async unlogMigration({ name: migrationName }) {
const loggedMigrations = await this.executed();
const updatedMigrations = loggedMigrations.filter(name => name !== migrationName);
await filesystem.writeAsync(this.path, JSON.stringify(updatedMigrations, null, 2));
}
async executed() {
const content = await filesystem.readAsync(this.path);
return content ? JSON.parse(content) : [];
}
}
exports.JSONStorage = JSONStorage;
//# sourceMappingURL=json.js.map

View File

@@ -0,0 +1,17 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.memoryStorage = void 0;
const memoryStorage = () => {
let executed = [];
return {
async logMigration({ name }) {
executed.push(name);
},
async unlogMigration({ name }) {
executed = executed.filter(n => n !== name);
},
executed: async () => [...executed],
};
};
exports.memoryStorage = memoryStorage;
//# sourceMappingURL=memory.js.map

View File

@@ -0,0 +1,31 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MongoDBStorage = void 0;
function isMongoDBCollectionOptions(arg) {
return Boolean(arg.collection);
}
class MongoDBStorage {
constructor(options) {
var _a, _b;
if (!options || (!options.collection && !options.connection)) {
throw new Error('MongoDB Connection or Collection required');
}
this.collection = isMongoDBCollectionOptions(options)
? options.collection
: options.connection.collection((_a = options.collectionName) !== null && _a !== void 0 ? _a : 'migrations');
this.connection = options.connection; // TODO remove this
this.collectionName = (_b = options.collectionName) !== null && _b !== void 0 ? _b : 'migrations'; // TODO remove this
}
async logMigration({ name: migrationName }) {
await this.collection.insertOne({ migrationName });
}
async unlogMigration({ name: migrationName }) {
await this.collection.deleteOne({ migrationName });
}
async executed() {
const records = await this.collection.find({}).sort({ migrationName: 1 }).toArray();
return records.map(r => r.migrationName);
}
}
exports.MongoDBStorage = MongoDBStorage;
//# sourceMappingURL=mongodb.js.map

View File

@@ -0,0 +1,85 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SequelizeStorage = void 0;
const DIALECTS_WITH_CHARSET_AND_COLLATE = new Set(['mysql', 'mariadb']);
class SequelizeStorage {
/**
Constructs Sequelize based storage. Migrations will be stored in a SequelizeMeta table using the given instance of Sequelize.
If a model is given, it will be used directly as the model for the SequelizeMeta table. Otherwise, it will be created automatically according to the given options.
If the table does not exist it will be created automatically upon the logging of the first migration.
*/
constructor(options) {
var _a, _b, _c, _d, _e, _f;
if (!options || (!options.model && !options.sequelize)) {
throw new Error('One of "sequelize" or "model" storage option is required');
}
this.sequelize = (_a = options.sequelize) !== null && _a !== void 0 ? _a : options.model.sequelize;
this.columnType = (_b = options.columnType) !== null && _b !== void 0 ? _b : this.sequelize.constructor.DataTypes.STRING;
this.columnName = (_c = options.columnName) !== null && _c !== void 0 ? _c : 'name';
this.timestamps = (_d = options.timestamps) !== null && _d !== void 0 ? _d : false;
this.modelName = (_e = options.modelName) !== null && _e !== void 0 ? _e : 'SequelizeMeta';
this.tableName = options.tableName;
this.schema = options.schema;
this.model = (_f = options.model) !== null && _f !== void 0 ? _f : this.getModel();
}
getModel() {
var _a;
if (this.sequelize.isDefined(this.modelName)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return this.sequelize.model(this.modelName);
}
const dialectName = (_a = this.sequelize.dialect) === null || _a === void 0 ? void 0 : _a.name;
const hasCharsetAndCollate = dialectName && DIALECTS_WITH_CHARSET_AND_COLLATE.has(dialectName);
return this.sequelize.define(this.modelName, {
[this.columnName]: {
type: this.columnType,
allowNull: false,
unique: true,
primaryKey: true,
autoIncrement: false,
},
}, {
tableName: this.tableName,
schema: this.schema,
timestamps: this.timestamps,
charset: hasCharsetAndCollate ? 'utf8' : undefined,
collate: hasCharsetAndCollate ? 'utf8_unicode_ci' : undefined,
});
}
async syncModel() {
await this.model.sync();
}
async logMigration({ name: migrationName }) {
await this.syncModel();
await this.model.create({
[this.columnName]: migrationName,
});
}
async unlogMigration({ name: migrationName }) {
await this.syncModel();
await this.model.destroy({
where: {
[this.columnName]: migrationName,
},
});
}
async executed() {
await this.syncModel();
const migrations = await this.model.findAll({ order: [[this.columnName, 'ASC']] });
return migrations.map(migration => {
const name = migration[this.columnName];
if (typeof name !== 'string') {
throw new TypeError(`Unexpected migration name type: expected string, got ${typeof name}`);
}
return name;
});
}
// TODO remove this
_model() {
return this.model;
}
}
exports.SequelizeStorage = SequelizeStorage;
//# sourceMappingURL=sequelize.js.map

View File

@@ -0,0 +1,32 @@
'use strict'
/* eslint-disable unicorn/template-indent */
// templates for migration file creation
Object.defineProperty(exports, '__esModule', { value: true })
exports.sqlDown = exports.sqlUp = exports.mjs = exports.ts = exports.js = void 0
exports.js = `
/** @type {import('umzug').MigrationFn<any>} */
exports.up = async params => {};
/** @type {import('umzug').MigrationFn<any>} */
exports.down = async params => {};
`.trimStart()
exports.ts = `
import type { MigrationFn } from 'umzug';
export const up: MigrationFn = async params => {};
export const down: MigrationFn = async params => {};
`.trimStart()
exports.mjs = `
/** @type {import('umzug').MigrationFn<any>} */
export const up = async params => {};
/** @type {import('umzug').MigrationFn<any>} */
export const down = async params => {};
`.trimStart()
exports.sqlUp = `
-- up migration
`.trimStart()
exports.sqlDown = `
-- down migration
`.trimStart()
//# sourceMappingURL=templates.js.map

View File

@@ -0,0 +1,12 @@
'use strict'
Object.defineProperty(exports, '__esModule', { value: true })
exports.RerunBehavior = void 0
exports.RerunBehavior = {
/** Hard error if an up migration that has already been run, or a down migration that hasn't, is encountered */
THROW: 'THROW',
/** Silently skip up migrations that have already been run, or down migrations that haven't */
SKIP: 'SKIP',
/** Re-run up migrations that have already been run, or down migrations that haven't */
ALLOW: 'ALLOW'
}
//# sourceMappingURL=types.js.map

386
server/libs/umzug/umzug.js Normal file
View File

@@ -0,0 +1,386 @@
'use strict'
var __createBinding =
(this && this.__createBinding) ||
(Object.create
? function (o, m, k, k2) {
if (k2 === undefined) k2 = k
var desc = Object.getOwnPropertyDescriptor(m, k)
if (!desc || ('get' in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = {
enumerable: true,
get: function () {
return m[k]
}
}
}
Object.defineProperty(o, k2, desc)
}
: function (o, m, k, k2) {
if (k2 === undefined) k2 = k
o[k2] = m[k]
})
var __setModuleDefault =
(this && this.__setModuleDefault) ||
(Object.create
? function (o, v) {
Object.defineProperty(o, 'default', { enumerable: true, value: v })
}
: function (o, v) {
o['default'] = v
})
var __importStar =
(this && this.__importStar) ||
function (mod) {
if (mod && mod.__esModule) return mod
var result = {}
if (mod != null) for (var k in mod) if (k !== 'default' && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k)
__setModuleDefault(result, mod)
return result
}
var __importDefault =
(this && this.__importDefault) ||
function (mod) {
return mod && mod.__esModule ? mod : { default: mod }
}
var _a
Object.defineProperty(exports, '__esModule', { value: true })
exports.Umzug = exports.MigrationError = void 0
const fs = __importStar(require('fs'))
const path = __importStar(require('path'))
const storage_1 = require('./storage')
const templates = __importStar(require('./templates'))
const types_1 = require('./types')
class MigrationError extends Error {
// TODO [>=4.0.0] Take a `{ cause: ... }` options bag like the default `Error`, it looks like this because of verror backwards-compatibility.
constructor(migration, original) {
super(`Migration ${migration.name} (${migration.direction}) failed: ${MigrationError.errorString(original)}`, {
cause: original
})
this.name = 'MigrationError'
this.migration = migration
}
// TODO [>=4.0.0] Remove this backwards-compatibility alias
get info() {
return this.migration
}
static errorString(cause) {
return cause instanceof Error ? `Original error: ${cause.message}` : `Non-error value thrown. See info for full props: ${cause}`
}
}
exports.MigrationError = MigrationError
class Umzug {
/** creates a new Umzug instance */
constructor(options) {
var _b
this.options = options
this.storage = (0, storage_1.verifyUmzugStorage)((_b = options.storage) !== null && _b !== void 0 ? _b : new storage_1.JSONStorage())
this.migrations = this.getMigrationsResolver(this.options.migrations)
}
logging(message) {
var _b
;(_b = this.options.logger) === null || _b === void 0 ? void 0 : _b.info(message)
}
/** Get the list of migrations which have already been applied */
async executed() {
return this.runCommand('executed', async ({ context }) => {
const list = await this._executed(context)
// We do the following to not expose the `up` and `down` functions to the user
return list.map((m) => ({ name: m.name, path: m.path }))
})
}
/** Get the list of migrations which have already been applied */
async _executed(context) {
const [migrations, executedNames] = await Promise.all([this.migrations(context), this.storage.executed({ context })])
const executedSet = new Set(executedNames)
return migrations.filter((m) => executedSet.has(m.name))
}
/** Get the list of migrations which are yet to be applied */
async pending() {
return this.runCommand('pending', async ({ context }) => {
const list = await this._pending(context)
// We do the following to not expose the `up` and `down` functions to the user
return list.map((m) => ({ name: m.name, path: m.path }))
})
}
async _pending(context) {
const [migrations, executedNames] = await Promise.all([this.migrations(context), this.storage.executed({ context })])
const executedSet = new Set(executedNames)
return migrations.filter((m) => !executedSet.has(m.name))
}
async runCommand(command, cb) {
const context = await this.getContext()
return await cb({ context })
}
/**
* Apply migrations. By default, runs all pending migrations.
* @see MigrateUpOptions for other use cases using `to`, `migrations` and `rerun`.
*/
async up(options = {}) {
const eligibleMigrations = async (context) => {
var _b
if (options.migrations && options.rerun === types_1.RerunBehavior.ALLOW) {
// Allow rerun means the specified migrations should be run even if they've run before - so get all migrations, not just pending
const list = await this.migrations(context)
return this.findMigrations(list, options.migrations)
}
if (options.migrations && options.rerun === types_1.RerunBehavior.SKIP) {
const executedNames = new Set((await this._executed(context)).map((m) => m.name))
const filteredMigrations = options.migrations.filter((m) => !executedNames.has(m))
return this.findMigrations(await this.migrations(context), filteredMigrations)
}
if (options.migrations) {
return this.findMigrations(await this._pending(context), options.migrations)
}
const allPending = await this._pending(context)
let sliceIndex = (_b = options.step) !== null && _b !== void 0 ? _b : allPending.length
if (options.to) {
sliceIndex = this.findNameIndex(allPending, options.to) + 1
}
return allPending.slice(0, sliceIndex)
}
return this.runCommand('up', async ({ context }) => {
const toBeApplied = await eligibleMigrations(context)
for (const m of toBeApplied) {
const start = Date.now()
const params = { name: m.name, path: m.path, context }
this.logging({ event: 'migrating', name: m.name })
try {
await m.up(params)
} catch (e) {
throw new MigrationError({ direction: 'up', ...params }, e)
}
await this.storage.logMigration(params)
const duration = (Date.now() - start) / 1000
this.logging({ event: 'migrated', name: m.name, durationSeconds: duration })
}
return toBeApplied.map((m) => ({ name: m.name, path: m.path }))
})
}
/**
* Revert migrations. By default, the last executed migration is reverted.
* @see MigrateDownOptions for other use cases using `to`, `migrations` and `rerun`.
*/
async down(options = {}) {
const eligibleMigrations = async (context) => {
var _b
if (options.migrations && options.rerun === types_1.RerunBehavior.ALLOW) {
const list = await this.migrations(context)
return this.findMigrations(list, options.migrations)
}
if (options.migrations && options.rerun === types_1.RerunBehavior.SKIP) {
const pendingNames = new Set((await this._pending(context)).map((m) => m.name))
const filteredMigrations = options.migrations.filter((m) => !pendingNames.has(m))
return this.findMigrations(await this.migrations(context), filteredMigrations)
}
if (options.migrations) {
return this.findMigrations(await this._executed(context), options.migrations)
}
const executedReversed = (await this._executed(context)).slice().reverse()
let sliceIndex = (_b = options.step) !== null && _b !== void 0 ? _b : 1
if (options.to === 0 || options.migrations) {
sliceIndex = executedReversed.length
} else if (options.to) {
sliceIndex = this.findNameIndex(executedReversed, options.to) + 1
}
return executedReversed.slice(0, sliceIndex)
}
return this.runCommand('down', async ({ context }) => {
var _b
const toBeReverted = await eligibleMigrations(context)
for (const m of toBeReverted) {
const start = Date.now()
const params = { name: m.name, path: m.path, context }
this.logging({ event: 'reverting', name: m.name })
try {
await ((_b = m.down) === null || _b === void 0 ? void 0 : _b.call(m, params))
} catch (e) {
throw new MigrationError({ direction: 'down', ...params }, e)
}
await this.storage.unlogMigration(params)
const duration = Number.parseFloat(((Date.now() - start) / 1000).toFixed(3))
this.logging({ event: 'reverted', name: m.name, durationSeconds: duration })
}
return toBeReverted.map((m) => ({ name: m.name, path: m.path }))
})
}
async create(options) {
await this.runCommand('create', async ({ context }) => {
var _b, _c, _d, _e
const isoDate = new Date().toISOString()
const prefixes = {
TIMESTAMP: isoDate.replace(/\.\d{3}Z$/, '').replace(/\W/g, '.'),
DATE: isoDate.split('T')[0].replace(/\W/g, '.'),
NONE: ''
}
const prefixType = (_b = options.prefix) !== null && _b !== void 0 ? _b : 'TIMESTAMP'
const fileBasename = [prefixes[prefixType], options.name].filter(Boolean).join('.')
const allowedExtensions = options.allowExtension ? [options.allowExtension] : ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.sql']
const existing = await this.migrations(context)
const last = existing.slice(-1)[0]
const folder = options.folder || ((_c = this.options.create) === null || _c === void 0 ? void 0 : _c.folder) || ((last === null || last === void 0 ? void 0 : last.path) && path.dirname(last.path))
if (!folder) {
throw new Error(`Couldn't infer a directory to generate migration file in. Pass folder explicitly`)
}
const filepath = path.join(folder, fileBasename)
if (!options.allowConfusingOrdering) {
const confusinglyOrdered = existing.find((e) => e.path && e.path >= filepath)
if (confusinglyOrdered) {
throw new Error(`Can't create ${fileBasename}, since it's unclear if it should run before or after existing migration ${confusinglyOrdered.name}. Use allowConfusingOrdering to bypass this error.`)
}
}
const template =
typeof options.content === 'string'
? async () => [[filepath, options.content]]
: // eslint-disable-next-line @typescript-eslint/unbound-method
(_e = (_d = this.options.create) === null || _d === void 0 ? void 0 : _d.template) !== null && _e !== void 0
? _e
: Umzug.defaultCreationTemplate
const toWrite = await template(filepath)
if (toWrite.length === 0) {
toWrite.push([filepath, ''])
}
toWrite.forEach((pair) => {
if (!Array.isArray(pair) || pair.length !== 2) {
throw new Error(`Expected [filepath, content] pair. Check that the file template function returns an array of pairs.`)
}
const ext = path.extname(pair[0])
if (!allowedExtensions.includes(ext)) {
const allowStr = allowedExtensions.join(', ')
const message = `Extension ${ext} not allowed. Allowed extensions are ${allowStr}. See help for allowExtension to avoid this error.`
throw new Error(message)
}
fs.mkdirSync(path.dirname(pair[0]), { recursive: true })
fs.writeFileSync(pair[0], pair[1])
this.logging({ event: 'created', path: pair[0] })
})
if (!options.skipVerify) {
const [firstFilePath] = toWrite[0]
const pending = await this._pending(context)
if (!pending.some((p) => p.path && path.resolve(p.path) === path.resolve(firstFilePath))) {
const paths = pending.map((p) => p.path).join(', ')
throw new Error(`Expected ${firstFilePath} to be a pending migration but it wasn't! Pending migration paths: ${paths}. You should investigate this. Use skipVerify to bypass this error.`)
}
}
})
}
static defaultCreationTemplate(filepath) {
const ext = path.extname(filepath)
if ((ext === '.js' && typeof require.main === 'object') || ext === '.cjs') {
return [[filepath, templates.js]]
}
if (ext === '.ts' || ext === '.mts' || ext === '.cts') {
return [[filepath, templates.ts]]
}
if ((ext === '.js' && require.main === undefined) || ext === '.mjs') {
return [[filepath, templates.mjs]]
}
if (ext === '.sql') {
const downFilepath = path.join(path.dirname(filepath), 'down', path.basename(filepath))
return [
[filepath, templates.sqlUp],
[downFilepath, templates.sqlDown]
]
}
return []
}
findNameIndex(migrations, name) {
const index = migrations.findIndex((m) => m.name === name)
if (index === -1) {
throw new Error(`Couldn't find migration to apply with name ${JSON.stringify(name)}`)
}
return index
}
findMigrations(migrations, names) {
const map = new Map(migrations.map((m) => [m.name, m]))
return names.map((name) => {
const migration = map.get(name)
if (!migration) {
throw new Error(`Couldn't find migration to apply with name ${JSON.stringify(name)}`)
}
return migration
})
}
async getContext() {
const { context = {} } = this.options
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return typeof context === 'function' ? context() : context
}
/** helper for parsing input migrations into a callback returning a list of ready-to-run migrations */
getMigrationsResolver(inputMigrations) {
var _b
if (Array.isArray(inputMigrations)) {
return async () => inputMigrations
}
if (typeof inputMigrations === 'function') {
// Lazy migrations definition, recurse.
return async (ctx) => {
const resolved = await inputMigrations(ctx)
return this.getMigrationsResolver(resolved)(ctx)
}
}
const paths = inputMigrations.files
const resolver = (_b = inputMigrations.resolve) !== null && _b !== void 0 ? _b : Umzug.defaultResolver
return async (context) => {
paths.sort()
return paths.map((unresolvedPath) => {
const filepath = path.resolve(unresolvedPath)
const name = path.basename(filepath)
return {
path: filepath,
...resolver({ name, path: filepath, context })
}
})
}
}
}
exports.Umzug = Umzug
_a = Umzug
Umzug.defaultResolver = ({ name, path: filepath }) => {
if (!filepath) {
throw new Error(`Can't use default resolver for non-filesystem migrations`)
}
const ext = path.extname(filepath)
const languageSpecificHelp = {
'.ts': "TypeScript files can be required by adding `ts-node` as a dependency and calling `require('ts-node/register')` at the program entrypoint before running migrations.",
'.sql': 'Try writing a resolver which reads file content and executes it as a sql query.'
}
languageSpecificHelp['.cts'] = languageSpecificHelp['.ts']
languageSpecificHelp['.mts'] = languageSpecificHelp['.ts']
let loadModule
const jsExt = ext.replace(/\.([cm]?)ts$/, '.$1js')
const getModule = async () => {
try {
return await loadModule()
} catch (e) {
if ((e instanceof SyntaxError || e instanceof MissingResolverError) && ext in languageSpecificHelp) {
e.message += '\n\n' + languageSpecificHelp[ext]
}
throw e
}
}
if ((jsExt === '.js' && typeof require.main === 'object') || jsExt === '.cjs') {
// eslint-disable-next-line @typescript-eslint/no-var-requires
loadModule = async () => require(filepath)
} else if (jsExt === '.js' || jsExt === '.mjs') {
loadModule = async () => import(filepath)
} else {
loadModule = async () => {
throw new MissingResolverError(filepath)
}
}
return {
name,
path: filepath,
up: async ({ context }) => (await getModule()).up({ path: filepath, name, context }),
down: async ({ context }) => {
var _b, _c
return (_c = (_b = await getModule()).down) === null || _c === void 0 ? void 0 : _c.call(_b, { path: filepath, name, context })
}
}
}
class MissingResolverError extends Error {
constructor(filepath) {
super(`No resolver specified for file ${filepath}. See docs for guidance on how to write a custom resolver.`)
}
}
//# sourceMappingURL=umzug.js.map

View File

@@ -0,0 +1,278 @@
const { Umzug, SequelizeStorage } = require('../libs/umzug')
const { Sequelize, DataTypes } = require('sequelize')
const semver = require('semver')
const path = require('path')
const Module = require('module')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
class MigrationManager {
static MIGRATIONS_META_TABLE = 'migrationsMeta'
/**
* @param {import('../Database').sequelize} sequelize
* @param {string} [configPath]
*/
constructor(sequelize, configPath = global.configPath) {
if (!sequelize || !(sequelize instanceof Sequelize)) throw new Error('Sequelize instance is required for MigrationManager.')
this.sequelize = sequelize
if (!configPath) throw new Error('Config path is required for MigrationManager.')
this.configPath = configPath
this.migrationsSourceDir = path.join(__dirname, '..', 'migrations')
this.initialized = false
this.migrationsDir = null
this.maxVersion = null
this.databaseVersion = null
this.serverVersion = null
this.umzug = null
}
/**
* Init version vars and copy migration files to config dir if necessary
*
* @param {string} serverVersion
*/
async init(serverVersion) {
if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`)
this.migrationsDir = path.join(this.configPath, 'migrations')
this.serverVersion = this.extractVersionFromTag(serverVersion)
if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`)
await this.fetchVersionsFromDatabase()
if (!this.maxVersion || !this.databaseVersion) throw new Error('Failed to fetch versions from the database.')
Logger.debug(`[MigrationManager] Database version: ${this.databaseVersion}, Max version: ${this.maxVersion}, Server version: ${this.serverVersion}`)
if (semver.gt(this.serverVersion, this.maxVersion)) {
try {
await this.copyMigrationsToConfigDir()
} catch (error) {
throw new Error('Failed to copy migrations to the config directory.', { cause: error })
}
try {
await this.updateMaxVersion()
} catch (error) {
throw new Error('Failed to update max version in the database.', { cause: error })
}
}
this.initialized = true
}
async runMigrations() {
if (!this.initialized) throw new Error('MigrationManager is not initialized. Call init() first.')
const versionCompare = semver.compare(this.serverVersion, this.databaseVersion)
if (versionCompare == 0) {
Logger.info('[MigrationManager] Database is already up to date.')
return
}
await this.initUmzug()
const migrations = await this.umzug.migrations()
const executedMigrations = (await this.umzug.executed()).map((m) => m.name)
const migrationDirection = versionCompare == 1 ? 'up' : 'down'
let migrationsToRun = []
migrationsToRun = this.findMigrationsToRun(migrations, executedMigrations, migrationDirection)
// Only proceed with migration if there are migrations to run
if (migrationsToRun.length > 0) {
const originalDbPath = path.join(this.configPath, 'absdatabase.sqlite')
const backupDbPath = path.join(this.configPath, 'absdatabase.backup.sqlite')
try {
Logger.info(`[MigrationManager] Migrating database ${migrationDirection} to version ${this.serverVersion}`)
Logger.info(`[MigrationManager] Migrations to run: ${migrationsToRun.join(', ')}`)
// Create a backup copy of the SQLite database before starting migrations
await fs.copy(originalDbPath, backupDbPath)
Logger.info('Created a backup of the original database.')
// Run migrations
await this.umzug[migrationDirection]({ migrations: migrationsToRun, rerun: 'ALLOW' })
// Clean up the backup
await fs.remove(backupDbPath)
Logger.info('[MigrationManager] Migrations successfully applied to the original database.')
} catch (error) {
Logger.error('[MigrationManager] Migration failed:', error)
await this.sequelize.close()
// Step 3: If migration fails, save the failed original and restore the backup
const failedDbPath = path.join(this.configPath, 'absdatabase.failed.sqlite')
await fs.move(originalDbPath, failedDbPath, { overwrite: true })
Logger.info('[MigrationManager] Saved the failed database as absdatabase.failed.sqlite.')
await fs.move(backupDbPath, originalDbPath, { overwrite: true })
Logger.info('[MigrationManager] Restored the original database from the backup.')
Logger.info('[MigrationManager] Migration failed. Exiting Audiobookshelf with code 1.')
process.exit(1)
}
} else {
Logger.info('[MigrationManager] No migrations to run.')
}
await this.updateDatabaseVersion()
}
async initUmzug(umzugStorage = new SequelizeStorage({ sequelize: this.sequelize })) {
// This check is for dependency injection in tests
const files = (await fs.readdir(this.migrationsDir)).map((file) => path.join(this.migrationsDir, file))
const parent = new Umzug({
migrations: {
files,
resolve: (params) => {
// make script think it's in migrationsSourceDir
const migrationPath = params.path
const migrationName = params.name
const contents = fs.readFileSync(migrationPath, 'utf8')
const fakePath = path.join(this.migrationsSourceDir, path.basename(migrationPath))
const module = new Module(fakePath)
module.filename = fakePath
module.paths = Module._nodeModulePaths(this.migrationsSourceDir)
module._compile(contents, fakePath)
const script = module.exports
return {
name: migrationName,
path: migrationPath,
up: script.up,
down: script.down
}
}
},
context: { queryInterface: this.sequelize.getQueryInterface(), logger: Logger },
storage: umzugStorage,
logger: Logger
})
// Sort migrations by version
this.umzug = new Umzug({
...parent.options,
migrations: async () =>
(await parent.migrations()).sort((a, b) => {
const versionA = this.extractVersionFromTag(a.name)
const versionB = this.extractVersionFromTag(b.name)
return semver.compare(versionA, versionB)
})
})
}
async fetchVersionsFromDatabase() {
await this.checkOrCreateMigrationsMetaTable()
const [{ version }] = await this.sequelize.query("SELECT value as version FROM :migrationsMeta WHERE key = 'version'", {
replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.SELECT
})
this.databaseVersion = version
const [{ maxVersion }] = await this.sequelize.query("SELECT value as maxVersion FROM :migrationsMeta WHERE key = 'maxVersion'", {
replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.SELECT
})
this.maxVersion = maxVersion
}
async checkOrCreateMigrationsMetaTable() {
const queryInterface = this.sequelize.getQueryInterface()
if (!(await queryInterface.tableExists(MigrationManager.MIGRATIONS_META_TABLE))) {
await queryInterface.createTable(MigrationManager.MIGRATIONS_META_TABLE, {
key: {
type: DataTypes.STRING,
allowNull: false
},
value: {
type: DataTypes.STRING,
allowNull: false
}
})
await this.sequelize.query("INSERT INTO :migrationsMeta (key, value) VALUES ('version', '0.0.0'), ('maxVersion', '0.0.0')", {
replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.INSERT
})
Logger.debug(`[MigrationManager] Created migrationsMeta table: "${MigrationManager.MIGRATIONS_META_TABLE}"`)
}
}
extractVersionFromTag(tag) {
if (!tag) return null
const versionMatch = tag.match(/^v?(\d+\.\d+\.\d+)/)
return versionMatch ? versionMatch[1] : null
}
async copyMigrationsToConfigDir() {
await fs.ensureDir(this.migrationsDir) // Ensure the target directory exists
if (!(await fs.pathExists(this.migrationsSourceDir))) return
const files = await fs.readdir(this.migrationsSourceDir)
await Promise.all(
files
.filter((file) => path.extname(file) === '.js')
.map(async (file) => {
const sourceFile = path.join(this.migrationsSourceDir, file)
const targetFile = path.join(this.migrationsDir, file)
await fs.copy(sourceFile, targetFile) // Asynchronously copy the files
})
)
Logger.debug(`[MigrationManager] Copied migrations to the config directory: "${this.migrationsDir}"`)
}
/**
*
* @param {{ name: string }[]} migrations
* @param {string[]} executedMigrations - names of executed migrations
* @param {string} direction - 'up' or 'down'
* @returns {string[]} - names of migrations to run
*/
findMigrationsToRun(migrations, executedMigrations, direction) {
const migrationsToRun = migrations
.filter((migration) => {
const migrationVersion = this.extractVersionFromTag(migration.name)
if (direction === 'up') {
return semver.gt(migrationVersion, this.databaseVersion) && semver.lte(migrationVersion, this.serverVersion) && !executedMigrations.includes(migration.name)
} else {
// A down migration should be run even if the associated up migration wasn't executed before
return semver.lte(migrationVersion, this.databaseVersion) && semver.gt(migrationVersion, this.serverVersion)
}
})
.map((migration) => migration.name)
if (direction === 'down') {
return migrationsToRun.reverse()
} else {
return migrationsToRun
}
}
async updateMaxVersion() {
try {
await this.sequelize.query("UPDATE :migrationsMeta SET value = :maxVersion WHERE key = 'maxVersion'", {
replacements: { maxVersion: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.UPDATE
})
} catch (error) {
throw new Error('Failed to update maxVersion in the migrationsMeta table.', { cause: error })
}
this.maxVersion = this.serverVersion
}
async updateDatabaseVersion() {
try {
await this.sequelize.query("UPDATE :migrationsMeta SET value = :version WHERE key = 'version'", {
replacements: { version: this.serverVersion, migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE },
type: Sequelize.QueryTypes.UPDATE
})
} catch (error) {
throw new Error('Failed to update version in the migrationsMeta table.', { cause: error })
}
this.databaseVersion = this.serverVersion
}
}
module.exports = MigrationManager

View File

@@ -51,16 +51,16 @@ class PlaybackSessionManager {
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion, req.user?.id)
if (clientDeviceInfo?.deviceId) {
const existingDevice = await Database.getDeviceByDeviceId(clientDeviceInfo.deviceId)
const existingDevice = await Database.deviceModel.getOldDeviceByDeviceId(clientDeviceInfo.deviceId)
if (existingDevice) {
if (existingDevice.update(deviceInfo)) {
await Database.updateDevice(existingDevice)
await Database.deviceModel.updateFromOld(existingDevice)
}
return existingDevice
}
}
await Database.createDevice(deviceInfo)
await Database.deviceModel.createFromOld(deviceInfo)
return deviceInfo
}
@@ -164,6 +164,7 @@ class PlaybackSessionManager {
// New session from local
session = new PlaybackSession(sessionJson)
session.deviceInfo = deviceInfo
session.setDuration(libraryItem, sessionJson.episodeId)
Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`)
await Database.createPlaybackSession(session)
} else {

View File

@@ -0,0 +1,7 @@
# Migrations Changelog
Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time.
| Server Version | Migration Script Name | Description |
| -------------- | --------------------- | ----------- |
| | | |

View File

@@ -0,0 +1,49 @@
# Database Migrations
This directory contains all the database migration scripts for the server.
## What is a migration?
A migration is a script that changes the structure of the database. This can include creating tables, adding columns, or modifying existing columns. A migration script consists of two parts: an "up" script that applies the changes to the database, and a "down" script that undoes the changes.
## Guidelines for writing migrations
When writing a migration, keep the following guidelines in mind:
- You **_must_** name your migration script according to the following convention: `<server_version>-<migration_name>.js`. For example, `v2.14.0-create-users-table.js`.
- `server_version` should be the version of the server that the migration was created for (this should usually be the next server release).
- `migration_name` should be a short description of the changes that the migration makes.
- The script should export two async functions: `up` and `down`. The `up` function should contain the script that applies the changes to the database, and the `down` function should contain the script that undoes the changes. The `up` and `down` functions should accept a single object parameter with a `context` property that contains a reference to a Sequelize [`QueryInterface`](https://sequelize.org/docs/v6/other-topics/query-interface/) object, and a [Logger](https://github.com/advplyr/audiobookshelf/blob/423a2129d10c6d8aaac9e8c75941fa6283889602/server/Logger.js#L4) object for logging. A typical migration script might look like this:
```javascript
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info('migrating ...');
...
}
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info('reverting ...');
...
}
module.exports = {up, down}
```
- Always implement both the `up` and `down` functions.
- The `up` and `down` functions should be idempotent (i.e., they should be safe to run multiple times).
- Prefer using only `queryInterface` and `logger` parameters, the `sequelize` module, and node.js built-in modules in your migration scripts. You can require other modules, but be aware that they might not be available or change from they ones you tested with.
- It's your responsibility to make sure that the down migration reverts the changes made by the up migration.
- Log detailed information on every step of the migration. Use `Logger.info()` and `Logger.error()`.
- Test tour migrations thoroughly before committing them.
- write unit tests for your migrations (see `test/server/migrations` for an example)
- you can force a server version change by modifying the `version` field in `package.json` on your dev environment (but don't forget to revert it back before committing)
## How migrations are run
Migrations are run automatically when the server starts, when the server detects that the server version has changed. Migrations are always run in server version order (from oldest to newest up migrations if the server version increased, and from newest to oldest down migrations if the server version decreased). Only the relevant migrations are run, based on the new and old server versions.
This means that you can switch between server releases without having to worry about running migrations manually. The server will automatically apply the necessary migrations when it starts.

View File

@@ -1,5 +1,6 @@
const { DataTypes, Model, where, fn, col } = require('sequelize')
const parseNameString = require('../utils/parsers/parseNameString')
const { asciiOnlyToLowerCase } = require('../utils/index')
class Author extends Model {
constructor(values, options) {
@@ -55,7 +56,7 @@ class Author extends Model {
static async getByNameAndLibrary(authorName, libraryId) {
return this.findOne({
where: [
where(fn('lower', col('name')), authorName.toLowerCase()),
where(fn('lower', col('name')), asciiOnlyToLowerCase(authorName)),
{
libraryId
}

View File

@@ -30,6 +30,61 @@ class CustomMetadataProvider extends Model {
this.updatedAt
}
/**
* Get providers for client by media type
* Currently only available for "book" media type
*
* @param {string} mediaType
* @returns {Promise<ClientCustomMetadataProvider[]>}
*/
static async getForClientByMediaType(mediaType) {
if (mediaType !== 'book') return []
const customMetadataProviders = await this.findAll({
where: {
mediaType
}
})
return customMetadataProviders.map((cmp) => cmp.toClientJson())
}
/**
* Check if provider exists by slug
*
* @param {string} providerSlug
* @returns {Promise<boolean>}
*/
static async checkExistsBySlug(providerSlug) {
const providerId = providerSlug?.split?.('custom-')[1]
if (!providerId) return false
return (await this.count({ where: { id: providerId } })) > 0
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
mediaType: DataTypes.STRING,
url: DataTypes.STRING,
authHeaderValue: DataTypes.STRING,
extraData: DataTypes.JSON
},
{
sequelize,
modelName: 'customMetadataProvider'
}
)
}
getSlug() {
return `custom-${this.id}`
}
@@ -46,58 +101,6 @@ class CustomMetadataProvider extends Model {
slug: this.getSlug()
}
}
/**
* Get providers for client by media type
* Currently only available for "book" media type
*
* @param {string} mediaType
* @returns {Promise<ClientCustomMetadataProvider[]>}
*/
static async getForClientByMediaType(mediaType) {
if (mediaType !== 'book') return []
const customMetadataProviders = await this.findAll({
where: {
mediaType
}
})
return customMetadataProviders.map(cmp => cmp.toClientJson())
}
/**
* Check if provider exists by slug
*
* @param {string} providerSlug
* @returns {Promise<boolean>}
*/
static async checkExistsBySlug(providerSlug) {
const providerId = providerSlug?.split?.('custom-')[1]
if (!providerId) return false
return (await this.count({ where: { id: providerId } })) > 0
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
mediaType: DataTypes.STRING,
url: DataTypes.STRING,
authHeaderValue: DataTypes.STRING,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'customMetadataProvider'
})
}
}
module.exports = CustomMetadataProvider
module.exports = CustomMetadataProvider

View File

@@ -29,33 +29,6 @@ class Device extends Model {
this.updatedAt
}
getOldDevice() {
let browserVersion = null
let sdkVersion = null
if (this.clientName === 'Abs Android') {
sdkVersion = this.deviceVersion || null
} else {
browserVersion = this.deviceVersion || null
}
return new oldDevice({
id: this.id,
deviceId: this.deviceId,
userId: this.userId,
ipAddress: this.ipAddress,
browserName: this.extraData.browserName || null,
browserVersion,
osName: this.extraData.osName || null,
osVersion: this.extraData.osVersion || null,
clientVersion: this.clientVersion || null,
manufacturer: this.extraData.manufacturer || null,
model: this.extraData.model || null,
sdkVersion,
deviceName: this.deviceName,
clientName: this.clientName
})
}
static async getOldDeviceByDeviceId(deviceId) {
const device = await this.findOne({
where: {
@@ -145,6 +118,60 @@ class Device extends Model {
})
Device.belongsTo(user)
}
toOldJSON() {
let browserVersion = null
let sdkVersion = null
if (this.clientName === 'Abs Android') {
sdkVersion = this.deviceVersion || null
} else {
browserVersion = this.deviceVersion || null
}
return {
id: this.id,
deviceId: this.deviceId,
userId: this.userId,
ipAddress: this.ipAddress,
browserName: this.extraData.browserName || null,
browserVersion,
osName: this.extraData.osName || null,
osVersion: this.extraData.osVersion || null,
clientVersion: this.clientVersion || null,
manufacturer: this.extraData.manufacturer || null,
model: this.extraData.model || null,
sdkVersion,
deviceName: this.deviceName,
clientName: this.clientName
}
}
getOldDevice() {
let browserVersion = null
let sdkVersion = null
if (this.clientName === 'Abs Android') {
sdkVersion = this.deviceVersion || null
} else {
browserVersion = this.deviceVersion || null
}
return new oldDevice({
id: this.id,
deviceId: this.deviceId,
userId: this.userId,
ipAddress: this.ipAddress,
browserName: this.extraData.browserName || null,
browserVersion,
osName: this.extraData.osName || null,
osVersion: this.extraData.osVersion || null,
clientVersion: this.clientVersion || null,
manufacturer: this.extraData.manufacturer || null,
model: this.extraData.model || null,
sdkVersion,
deviceName: this.deviceName,
clientName: this.clientName
})
}
}
module.exports = Device

View File

@@ -1,6 +1,7 @@
const { DataTypes, Model, where, fn, col } = require('sequelize')
const { getTitlePrefixAtEnd } = require('../utils/index')
const { asciiOnlyToLowerCase } = require('../utils/index')
class Series extends Model {
constructor(values, options) {
@@ -41,7 +42,7 @@ class Series extends Model {
static async getByNameAndLibrary(seriesName, libraryId) {
return this.findOne({
where: [
where(fn('lower', col('name')), seriesName.toLowerCase()),
where(fn('lower', col('name')), asciiOnlyToLowerCase(seriesName)),
{
libraryId
}

View File

@@ -107,7 +107,7 @@ class User extends Model {
upload: type === 'root' || type === 'admin',
accessAllLibraries: true,
accessAllTags: true,
accessExplicitContent: true,
accessExplicitContent: type === 'root' || type === 'admin',
selectedTagsNotAccessible: false,
librariesAccessible: [],
itemTagsSelected: []

View File

@@ -82,8 +82,8 @@ class PlaybackSession {
/**
* Session data to send to clients
* @param {[oldLibraryItem]} libraryItem optional
* @returns {object}
* @param {Object} [libraryItem] - old library item
* @returns
*/
toJSONForClient(libraryItem) {
return {
@@ -219,11 +219,7 @@ class PlaybackSession {
this.displayAuthor = libraryItem.media.getPlaybackAuthor()
this.coverPath = libraryItem.media.coverPath
if (episodeId) {
this.duration = libraryItem.media.getEpisodeDuration(episodeId)
} else {
this.duration = libraryItem.media.duration
}
this.setDuration(libraryItem, episodeId)
this.mediaPlayer = mediaPlayer
this.deviceInfo = deviceInfo || new DeviceInfo()
@@ -239,6 +235,14 @@ class PlaybackSession {
this.updatedAt = Date.now()
}
setDuration(libraryItem, episodeId) {
if (episodeId) {
this.duration = libraryItem.media.getEpisodeDuration(episodeId)
} else {
this.duration = libraryItem.media.duration
}
}
addListeningTime(timeListened) {
if (!timeListened || isNaN(timeListened)) return
@@ -251,11 +255,5 @@ class PlaybackSession {
this.timeListening += Number.parseFloat(timeListened)
this.updatedAt = Date.now()
}
// New date since start of listening session
checkDateRollover() {
if (!this.date) return false
return date.format(new Date(), 'YYYY-MM-DD') !== this.date
}
}
module.exports = PlaybackSession

View File

@@ -233,15 +233,6 @@ class Podcast {
this.episodes.push(podcastEpisode)
}
addNewEpisodeFromAudioFile(audioFile, index) {
const pe = new PodcastEpisode()
pe.libraryItemId = this.libraryItemId
pe.podcastId = this.id
audioFile.index = 1 // Only 1 audio file per episode
pe.setDataFromAudioFile(audioFile, index)
this.episodes.push(pe)
}
removeEpisode(episodeId) {
const episode = this.episodes.find((ep) => ep.id === episodeId)
if (episode) {

View File

@@ -6,7 +6,7 @@ class BookMetadata {
this.title = null
this.subtitle = null
this.authors = []
this.narrators = [] // Array of strings
this.narrators = [] // Array of strings
this.series = []
this.genres = [] // Array of strings
this.publishedYear = null
@@ -27,9 +27,9 @@ class BookMetadata {
construct(metadata) {
this.title = metadata.title
this.subtitle = metadata.subtitle
this.authors = (metadata.authors?.map) ? metadata.authors.map(a => ({ ...a })) : []
this.narrators = metadata.narrators ? [...metadata.narrators].filter(n => n) : []
this.series = (metadata.series?.map) ? metadata.series.map(s => ({ ...s })) : []
this.authors = metadata.authors?.map ? metadata.authors.map((a) => ({ ...a })) : []
this.narrators = metadata.narrators ? [...metadata.narrators].filter((n) => n) : []
this.series = metadata.series?.map ? metadata.series.map((s) => ({ ...s })) : []
this.genres = metadata.genres ? [...metadata.genres] : []
this.publishedYear = metadata.publishedYear || null
this.publishedDate = metadata.publishedDate || null
@@ -46,9 +46,9 @@ class BookMetadata {
return {
title: this.title,
subtitle: this.subtitle,
authors: this.authors.map(a => ({ ...a })), // Author JSONMinimal with name and id
authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id
narrators: [...this.narrators],
series: this.series.map(s => ({ ...s })), // Series JSONMinimal with name, id and sequence
series: this.series.map((s) => ({ ...s })), // Series JSONMinimal with name, id and sequence
genres: [...this.genres],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
@@ -89,9 +89,9 @@ class BookMetadata {
title: this.title,
titleIgnorePrefix: this.titlePrefixAtEnd,
subtitle: this.subtitle,
authors: this.authors.map(a => ({ ...a })), // Author JSONMinimal with name and id
authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id
narrators: [...this.narrators],
series: this.series.map(s => ({ ...s })),
series: this.series.map((s) => ({ ...s })),
genres: [...this.genres],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
@@ -111,8 +111,8 @@ class BookMetadata {
toJSONForMetadataFile() {
const json = this.toJSON()
json.authors = json.authors.map(au => au.name)
json.series = json.series.map(se => {
json.authors = json.authors.map((au) => au.name)
json.series = json.series.map((se) => {
if (!se.sequence) return se.name
return `${se.name} #${se.sequence}`
})
@@ -131,36 +131,31 @@ class BookMetadata {
}
get authorName() {
if (!this.authors.length) return ''
return this.authors.map(au => au.name).join(', ')
return this.authors.map((au) => au.name).join(', ')
}
get authorNameLF() { // Last, First
get authorNameLF() {
// Last, First
if (!this.authors.length) return ''
return this.authors.map(au => parseNameString.nameToLastFirst(au.name)).join(', ')
return this.authors.map((au) => parseNameString.nameToLastFirst(au.name)).join(', ')
}
get seriesName() {
if (!this.series.length) return ''
return this.series.map(se => {
if (!se.sequence) return se.name
return `${se.name} #${se.sequence}`
}).join(', ')
}
get firstSeriesName() {
if (!this.series.length) return ''
return this.series[0].name
}
get firstSeriesSequence() {
if (!this.series.length) return ''
return this.series[0].sequence
return this.series
.map((se) => {
if (!se.sequence) return se.name
return `${se.name} #${se.sequence}`
})
.join(', ')
}
get narratorName() {
return this.narrators.join(', ')
}
getSeries(seriesId) {
return this.series.find(se => se.id == seriesId)
return this.series.find((se) => se.id == seriesId)
}
getSeriesSequence(seriesId) {
const series = this.series.find(se => se.id == seriesId)
const series = this.series.find((se) => se.id == seriesId)
if (!series) return null
return series.sequence || ''
}
@@ -180,21 +175,5 @@ class BookMetadata {
}
return hasUpdates
}
// Updates author name
updateAuthor(updatedAuthor) {
const author = this.authors.find(au => au.id === updatedAuthor.id)
if (!author || author.name == updatedAuthor.name) return false
author.name = updatedAuthor.name
return true
}
replaceAuthor(oldAuthor, newAuthor) {
this.authors = this.authors.filter(au => au.id !== oldAuthor.id) // Remove old author
this.authors.push({
id: newAuthor.id,
name: newAuthor.name
})
}
}
module.exports = BookMetadata

View File

@@ -75,13 +75,14 @@ class LibraryScan {
return date.format(new Date(), 'YYYY-MM-DD') + '_' + this.id + '.txt'
}
get scanResultsString() {
if (this.error) return this.error
const strs = []
if (this.resultsAdded) strs.push(`${this.resultsAdded} added`)
if (this.resultsUpdated) strs.push(`${this.resultsUpdated} updated`)
if (this.resultsMissing) strs.push(`${this.resultsMissing} missing`)
if (!strs.length) return `Everything was up to date (${elapsedPretty(this.elapsed / 1000)})`
return strs.join(', ') + ` (${elapsedPretty(this.elapsed / 1000)})`
const changesDetected = strs.length > 0 ? strs.join(', ') : 'No changes detected'
const timeElapsed = `(${elapsedPretty(this.elapsed / 1000)})`
const error = this.error ? `${this.error}. ` : ''
return `${error}${changesDetected} ${timeElapsed}`
}
toJSON() {

View File

@@ -79,43 +79,39 @@ class LibraryScanner {
Logger.info(`[LibraryScanner] Starting${forceRescan ? ' (forced)' : ''} library scan ${libraryScan.id} for ${libraryScan.libraryName}`)
const canceled = await this.scanLibrary(libraryScan, forceRescan)
try {
const canceled = await this.scanLibrary(libraryScan, forceRescan)
libraryScan.setComplete()
if (canceled) {
Logger.info(`[LibraryScanner] Library scan canceled for "${libraryScan.libraryName}"`)
delete this.cancelLibraryScan[libraryScan.libraryId]
Logger.info(`[LibraryScanner] Library scan "${libraryScan.id}" ${canceled ? 'canceled after' : 'completed in'} ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}`)
if (!canceled) {
library.lastScan = Date.now()
library.lastScanVersion = packageJson.version
if (library.isBook) {
const newExtraData = library.extraData || {}
newExtraData.lastScanMetadataPrecedence = library.settings.metadataPrecedence
library.extraData = newExtraData
library.changed('extraData', true)
}
await library.save()
}
task.setFinished(`${canceled ? 'Canceled' : 'Completed'}. ${libraryScan.scanResultsString}`)
} catch (err) {
libraryScan.setComplete(err)
Logger.error(`[LibraryScanner] Library scan ${libraryScan.id} failed after ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}.`, err)
task.setFailed(`Failed. ${libraryScan.scanResultsString}`)
}
libraryScan.setComplete()
Logger.info(`[LibraryScanner] Library scan ${libraryScan.id} completed in ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}`)
if (this.cancelLibraryScan[libraryScan.libraryId]) delete this.cancelLibraryScan[libraryScan.libraryId]
this.librariesScanning = this.librariesScanning.filter((ls) => ls.id !== library.id)
if (canceled && !libraryScan.totalResults) {
task.setFinished('Scan canceled')
TaskManager.taskFinished(task)
const emitData = libraryScan.getScanEmitData
emitData.results = null
return
}
library.lastScan = Date.now()
library.lastScanVersion = packageJson.version
if (library.isBook) {
const newExtraData = library.extraData || {}
newExtraData.lastScanMetadataPrecedence = library.settings.metadataPrecedence
library.extraData = newExtraData
library.changed('extraData', true)
}
await library.save()
task.setFinished(libraryScan.scanResultsString)
TaskManager.taskFinished(task)
if (libraryScan.totalResults) {
libraryScan.saveLog()
}
libraryScan.saveLog()
}
/**
@@ -140,7 +136,7 @@ class LibraryScanner {
libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder)
}
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
if (this.shouldCancelScan(libraryScan)) return true
const existingLibraryItems = await Database.libraryItemModel.findAll({
where: {
@@ -148,7 +144,7 @@ class LibraryScanner {
}
})
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
if (this.shouldCancelScan(libraryScan)) return true
const libraryItemIdsMissing = []
let oldLibraryItemsUpdated = []
@@ -216,7 +212,7 @@ class LibraryScanner {
oldLibraryItemsUpdated = []
}
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
if (this.shouldCancelScan(libraryScan)) return true
}
// Emit item updates to client
if (oldLibraryItemsUpdated.length) {
@@ -247,7 +243,7 @@ class LibraryScanner {
)
}
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
if (this.shouldCancelScan(libraryScan)) return true
// Add new library items
if (libraryItemDataFound.length) {
@@ -271,7 +267,7 @@ class LibraryScanner {
newOldLibraryItems = []
}
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
if (this.shouldCancelScan(libraryScan)) return true
}
// Emit new items to client
if (newOldLibraryItems.length) {
@@ -282,6 +278,17 @@ class LibraryScanner {
)
}
}
libraryScan.addLog(LogLevel.INFO, `Scan completed. ${libraryScan.resultStats}`)
return false
}
shouldCancelScan(libraryScan) {
if (this.cancelLibraryScan[libraryScan.libraryId]) {
libraryScan.addLog(LogLevel.INFO, `Scan canceled. ${libraryScan.resultStats}`)
return true
}
return false
}
/**

285
test/server/Logger.test.js Normal file
View File

@@ -0,0 +1,285 @@
const { expect } = require('chai')
const sinon = require('sinon')
const Logger = require('../../server/Logger') // Adjust the path as needed
const { LogLevel } = require('../../server/utils/constants')
const date = require('../../server/libs/dateAndTime')
const util = require('util')
describe('Logger', function () {
let consoleTraceStub
let consoleDebugStub
let consoleInfoStub
let consoleWarnStub
let consoleErrorStub
let consoleLogStub
beforeEach(function () {
// Stub the date format function to return a consistent timestamp
sinon.stub(date, 'format').returns('2024-09-10 12:34:56.789')
// Stub the source getter to return a consistent source
sinon.stub(Logger, 'source').get(() => 'some/source.js')
// Stub the console methods used in Logger
consoleTraceStub = sinon.stub(console, 'trace')
consoleDebugStub = sinon.stub(console, 'debug')
consoleInfoStub = sinon.stub(console, 'info')
consoleWarnStub = sinon.stub(console, 'warn')
consoleErrorStub = sinon.stub(console, 'error')
consoleLogStub = sinon.stub(console, 'log')
// Initialize the Logger's logManager as a mock object
Logger.logManager = {
logToFile: sinon.stub().resolves()
}
})
afterEach(function () {
sinon.restore()
})
describe('logging methods', function () {
it('should have a method for each log level defined in the static block', function () {
const loggerMethods = Object.keys(LogLevel).map((key) => key.toLowerCase())
loggerMethods.forEach((method) => {
expect(Logger).to.have.property(method).that.is.a('function')
})
})
it('should call console.trace for trace logging', function () {
// Arrange
Logger.logLevel = LogLevel.TRACE
// Act
Logger.trace('Test message')
// Assert
expect(consoleTraceStub.calledOnce).to.be.true
})
it('should call console.debug for debug logging', function () {
// Arrange
Logger.logLevel = LogLevel.TRACE
// Act
Logger.debug('Test message')
// Assert
expect(consoleDebugStub.calledOnce).to.be.true
})
it('should call console.info for info logging', function () {
// Arrange
Logger.logLevel = LogLevel.TRACE
// Act
Logger.info('Test message')
// Assert
expect(consoleInfoStub.calledOnce).to.be.true
})
it('should call console.warn for warn logging', function () {
// Arrange
Logger.logLevel = LogLevel.TRACE
// Act
Logger.warn('Test message')
// Assert
expect(consoleWarnStub.calledOnce).to.be.true
})
it('should call console.error for error logging', function () {
// Arrange
Logger.logLevel = LogLevel.TRACE
// Act
Logger.error('Test message')
// Assert
expect(consoleErrorStub.calledOnce).to.be.true
})
it('should call console.error for fatal logging', function () {
// Arrange
Logger.logLevel = LogLevel.TRACE
// Act
Logger.fatal('Test message')
// Assert
expect(consoleErrorStub.calledOnce).to.be.true
})
it('should call console.log for note logging', function () {
// Arrange
Logger.logLevel = LogLevel.TRACE
// Act
Logger.note('Test message')
// Assert
expect(consoleLogStub.calledOnce).to.be.true
})
})
describe('#log', function () {
it('should log to console and file if level is high enough', async function () {
// Arrange
const logArgs = ['Test message']
Logger.logLevel = LogLevel.TRACE
// Act
Logger.debug(...logArgs)
expect(consoleDebugStub.calledOnce).to.be.true
expect(consoleDebugStub.calledWithExactly('[2024-09-10 12:34:56.789] DEBUG:', ...logArgs)).to.be.true
expect(Logger.logManager.logToFile.calledOnce).to.be.true
expect(
Logger.logManager.logToFile.calledWithExactly({
timestamp: '2024-09-10 12:34:56.789',
source: 'some/source.js',
message: 'Test message',
levelName: 'DEBUG',
level: LogLevel.DEBUG
})
).to.be.true
})
it('should not log if log level is too low', function () {
// Arrange
const logArgs = ['This log should not appear']
// Set log level to ERROR, so DEBUG log should be ignored
Logger.logLevel = LogLevel.ERROR
// Act
Logger.debug(...logArgs)
// Verify console.debug is not called
expect(consoleDebugStub.called).to.be.false
expect(Logger.logManager.logToFile.called).to.be.false
})
it('should emit log to all connected sockets with appropriate log level', async function () {
// Arrange
const socket1 = { id: '1', emit: sinon.spy() }
const socket2 = { id: '2', emit: sinon.spy() }
Logger.addSocketListener(socket1, LogLevel.DEBUG)
Logger.addSocketListener(socket2, LogLevel.ERROR)
const logArgs = ['Socket test']
Logger.logLevel = LogLevel.TRACE
// Act
await Logger.debug(...logArgs)
// socket1 should receive the log, but not socket2
expect(socket1.emit.calledOnce).to.be.true
expect(
socket1.emit.calledWithExactly('log', {
timestamp: '2024-09-10 12:34:56.789',
source: 'some/source.js',
message: 'Socket test',
levelName: 'DEBUG',
level: LogLevel.DEBUG
})
).to.be.true
expect(socket2.emit.called).to.be.false
})
it('should log fatal messages to console and file regardless of log level', async function () {
// Arrange
const logArgs = ['Fatal error']
// Set log level to NOTE + 1, so nothing should be logged
Logger.logLevel = LogLevel.NOTE + 1
// Act
await Logger.fatal(...logArgs)
// Assert
expect(consoleErrorStub.calledOnce).to.be.true
expect(consoleErrorStub.calledWithExactly('[2024-09-10 12:34:56.789] FATAL:', ...logArgs)).to.be.true
expect(Logger.logManager.logToFile.calledOnce).to.be.true
expect(
Logger.logManager.logToFile.calledWithExactly({
timestamp: '2024-09-10 12:34:56.789',
source: 'some/source.js',
message: 'Fatal error',
levelName: 'FATAL',
level: LogLevel.FATAL
})
).to.be.true
})
it('should log note messages to console and file regardless of log level', async function () {
// Arrange
const logArgs = ['Note message']
// Set log level to NOTE + 1, so nothing should be logged
Logger.logLevel = LogLevel.NOTE + 1
// Act
await Logger.note(...logArgs)
// Assert
expect(consoleLogStub.calledOnce).to.be.true
expect(consoleLogStub.calledWithExactly('[2024-09-10 12:34:56.789] NOTE:', ...logArgs)).to.be.true
expect(Logger.logManager.logToFile.calledOnce).to.be.true
expect(
Logger.logManager.logToFile.calledWithExactly({
timestamp: '2024-09-10 12:34:56.789',
source: 'some/source.js',
message: 'Note message',
levelName: 'NOTE',
level: LogLevel.NOTE
})
).to.be.true
})
it('should log util.inspect(arg) for non-string objects', async function () {
// Arrange
const obj = { key: 'value' }
const logArgs = ['Logging object:', obj]
Logger.logLevel = LogLevel.TRACE
// Act
await Logger.debug(...logArgs)
// Assert
expect(consoleDebugStub.calledOnce).to.be.true
expect(consoleDebugStub.calledWithExactly('[2024-09-10 12:34:56.789] DEBUG:', 'Logging object:', obj)).to.be.true
expect(Logger.logManager.logToFile.calledOnce).to.be.true
expect(Logger.logManager.logToFile.firstCall.args[0].message).to.equal('Logging object: ' + util.inspect(obj))
})
})
describe('socket listeners', function () {
it('should add and remove socket listeners', function () {
// Arrange
const socket1 = { id: '1', emit: sinon.spy() }
const socket2 = { id: '2', emit: sinon.spy() }
// Act
Logger.addSocketListener(socket1, LogLevel.DEBUG)
Logger.addSocketListener(socket2, LogLevel.ERROR)
Logger.removeSocketListener('1')
// Assert
expect(Logger.socketListeners).to.have.lengthOf(1)
expect(Logger.socketListeners[0].id).to.equal('2')
})
})
describe('setLogLevel', function () {
it('should change the log level and log the new level', function () {
// Arrange
const debugSpy = sinon.spy(Logger, 'debug')
// Act
Logger.setLogLevel(LogLevel.WARN)
// Assert
expect(Logger.logLevel).to.equal(LogLevel.WARN)
expect(debugSpy.calledOnce).to.be.true
expect(debugSpy.calledWithExactly('Set Log Level to WARN')).to.be.true
})
})
})

View File

@@ -22,7 +22,7 @@ describe('TitleCandidates', () => {
})
describe('single add', () => {
[
;[
['adds candidate', 'anna karenina', ['anna karenina']],
['adds lowercased candidate', 'ANNA KARENINA', ['anna karenina']],
['adds candidate, removing redundant spaces', 'anna karenina', ['anna karenina']],
@@ -40,23 +40,27 @@ describe('TitleCandidates', () => {
['adds candidate + variant, removing preceding/trailing numbers', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']],
['does not add empty candidate', '', []],
['does not add spaces-only candidate', ' ', []],
['does not add empty variant', '1984', ['1984']],
].forEach(([name, title, expected]) => it(name, () => {
titleCandidates.add(title)
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
}))
['does not add empty variant', '1984', ['1984']]
].forEach(([name, title, expected]) =>
it(name, () => {
titleCandidates.add(title)
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
})
)
})
describe('multiple adds', () => {
[
;[
['demotes digits-only candidates', ['01', 'anna karenina'], ['anna karenina', '01']],
['promotes transformed variants', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']],
['orders by position', ['title2', 'title1'], ['title2', 'title1']],
['dedupes candidates', ['title1', 'title1'], ['title1']],
].forEach(([name, titles, expected]) => it(name, () => {
for (const title of titles) titleCandidates.add(title)
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
}))
['dedupes candidates', ['title1', 'title1'], ['title1']]
].forEach(([name, titles, expected]) =>
it(name, () => {
for (const title of titles) titleCandidates.add(title)
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
})
)
})
})
@@ -69,12 +73,12 @@ describe('TitleCandidates', () => {
})
describe('single add', () => {
[
['adds a candidate', 'leo tolstoy', ['leo tolstoy']],
].forEach(([name, title, expected]) => it(name, () => {
titleCandidates.add(title)
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
}))
;[['adds a candidate', 'leo tolstoy', ['leo tolstoy']]].forEach(([name, title, expected]) =>
it(name, () => {
titleCandidates.add(title)
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
})
)
})
})
})
@@ -82,11 +86,7 @@ describe('TitleCandidates', () => {
describe('AuthorCandidates', () => {
let authorCandidates
const audnexus = {
authorASINsRequest: sinon.stub().resolves([
{ name: 'Leo Tolstoy' },
{ name: 'Nikolai Gogol' },
{ name: 'J. K. Rowling' },
]),
authorASINsRequest: sinon.stub().resolves([{ name: 'Leo Tolstoy' }, { name: 'Nikolai Gogol' }, { name: 'J. K. Rowling' }])
}
describe('cleanAuthor is null', () => {
@@ -95,15 +95,15 @@ describe('AuthorCandidates', () => {
})
describe('no adds', () => {
[
['returns empty author candidate', []],
].forEach(([name, expected]) => it(name, async () => {
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
;[['returns empty author candidate', []]].forEach(([name, expected]) =>
it(name, async () => {
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
})
)
})
describe('single add', () => {
[
;[
['adds recognized candidate', 'nikolai gogol', ['nikolai gogol']],
['does not add unrecognized candidate', 'fyodor dostoevsky', []],
['adds recognized author if candidate is a superstring', 'dr. nikolai gogol', ['nikolai gogol']],
@@ -112,21 +112,25 @@ describe('AuthorCandidates', () => {
['does not add candidate if edit distance from any recognized author is large', 'nikolai google', []],
['adds normalized recognized candidate (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']],
['adds normalized recognized candidate (et al removed)', 'nikolai gogol et al.', ['nikolai gogol']],
['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']],
].forEach(([name, author, expected]) => it(name, async () => {
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']]
].forEach(([name, author, expected]) =>
it(name, async () => {
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
})
)
})
describe('multi add', () => {
[
;[
['adds recognized author candidates', ['nikolai gogol', 'leo tolstoy'], ['nikolai gogol', 'leo tolstoy']],
['dedupes author candidates', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']],
].forEach(([name, authors, expected]) => it(name, async () => {
for (const author of authors) authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
['dedupes author candidates', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']]
].forEach(([name, authors, expected]) =>
it(name, async () => {
for (const author of authors) authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
})
)
})
})
@@ -138,21 +142,23 @@ describe('AuthorCandidates', () => {
})
describe('no adds', () => {
[
['adds cleanAuthor as candidate', [cleanAuthor]],
].forEach(([name, expected]) => it(name, async () => {
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
;[['adds cleanAuthor as candidate', [cleanAuthor]]].forEach(([name, expected]) =>
it(name, async () => {
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
})
)
})
describe('single add', () => {
[
;[
['adds recognized candidate', 'nikolai gogol', [cleanAuthor, 'nikolai gogol']],
['does not add candidate if it is a dupe of cleanAuthor', cleanAuthor, [cleanAuthor]],
].forEach(([name, author, expected]) => it(name, async () => {
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
['does not add candidate if it is a dupe of cleanAuthor', cleanAuthor, [cleanAuthor]]
].forEach(([name, author, expected]) =>
it(name, async () => {
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
})
)
})
})
@@ -164,43 +170,47 @@ describe('AuthorCandidates', () => {
})
describe('no adds', () => {
[
['adds cleanAuthor as candidate', [cleanAuthor]],
].forEach(([name, expected]) => it(name, async () => {
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
;[['adds cleanAuthor as candidate', [cleanAuthor]]].forEach(([name, expected]) =>
it(name, async () => {
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
})
)
})
describe('single add', () => {
[
;[
['adds recognized candidate and removes cleanAuthor', 'nikolai gogol', ['nikolai gogol']],
['does not add unrecognized candidate', 'jackie chan', [cleanAuthor]],
].forEach(([name, author, expected]) => it(name, async () => {
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
['does not add unrecognized candidate', 'jackie chan', [cleanAuthor]]
].forEach(([name, author, expected]) =>
it(name, async () => {
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
})
)
})
})
describe('cleanAuthor is unrecognized and dirty', () => {
describe('no adds', () => {
[
;[
['adds aggressively cleaned cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', ['fyodor dostoevsky']],
['adds cleanAuthor if aggresively cleaned cleanAuthor is empty', ', jackie chan', [', jackie chan']],
].forEach(([name, cleanAuthor, expected]) => it(name, async () => {
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
['adds cleanAuthor if aggresively cleaned cleanAuthor is empty', ', jackie chan', [', jackie chan']]
].forEach(([name, cleanAuthor, expected]) =>
it(name, async () => {
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
})
)
})
describe('single add', () => {
[
['adds recognized candidate and removes cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', 'nikolai gogol', ['nikolai gogol']],
].forEach(([name, cleanAuthor, author, expected]) => it(name, async () => {
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
;[['adds recognized candidate and removes cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', 'nikolai gogol', ['nikolai gogol']]].forEach(([name, cleanAuthor, author, expected]) =>
it(name, async () => {
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
})
)
})
})
})
@@ -211,16 +221,21 @@ describe('search', () => {
const u = 'unrecognized'
const r = ['book']
const runSearchStub = sinon.stub(bookFinder, 'runSearch')
runSearchStub.resolves([])
runSearchStub.withArgs(t, a).resolves(r)
runSearchStub.withArgs(t, u).resolves(r)
const audnexusStub = sinon.stub(bookFinder.audnexus, 'authorASINsRequest')
audnexusStub.resolves([{ name: a }])
let runSearchStub
let audnexusStub
beforeEach(() => {
bookFinder.runSearch.resetHistory()
runSearchStub = sinon.stub(bookFinder, 'runSearch')
runSearchStub.resolves([])
runSearchStub.withArgs(t, a).resolves(r)
runSearchStub.withArgs(t, u).resolves(r)
audnexusStub = sinon.stub(bookFinder.audnexus, 'authorASINsRequest')
audnexusStub.resolves([{ name: a }])
})
afterEach(() => {
sinon.restore()
})
describe('search title is empty', () => {
@@ -238,50 +253,26 @@ describe('search', () => {
})
describe('search title contains recognized title and search author is a recognized author', () => {
[
[`${t} -`],
[`${t} - ${a}`],
[`${a} - ${t}`],
[`${t}- ${a}`],
[`${t} -${a}`],
[`${t} ${a}`],
[`${a} - ${t} (unabridged)`],
[`${a} - ${t} (subtitle) - mp3`],
[`${t} {narrator} - series-01 64kbps 10:00:00`],
[`${a} - ${t} (2006) narrated by narrator [unabridged]`],
[`${t} - ${a} 2022 mp3`],
[`01 ${t}`],
[`2022_${t}_HQ`],
].forEach(([searchTitle]) => {
;[[`${t} -`], [`${t} - ${a}`], [`${a} - ${t}`], [`${t}- ${a}`], [`${t} -${a}`], [`${t} ${a}`], [`${a} - ${t} (unabridged)`], [`${a} - ${t} (subtitle) - mp3`], [`${t} {narrator} - series-01 64kbps 10:00:00`], [`${a} - ${t} (2006) narrated by narrator [unabridged]`], [`${t} - ${a} 2022 mp3`], [`01 ${t}`], [`2022_${t}_HQ`]].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => {
expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 2)
})
});
[
[`s-01 - ${t} (narrator) 64kbps 10:00:00`],
[`${a} - series 01 - ${t}`],
].forEach(([searchTitle]) => {
})
;[[`s-01 - ${t} (narrator) 64kbps 10:00:00`], [`${a} - series 01 - ${t}`]].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => {
expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 3)
})
});
[
[`${t}-${a}`],
[`${t} junk`],
].forEach(([searchTitle]) => {
})
;[[`${t}-${a}`], [`${t} junk`]].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns an empty result`, async () => {
expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal([])
})
})
describe('maxFuzzySearches = 0', () => {
[
[`${t} - ${a}`],
].forEach(([searchTitle]) => {
;[[`${t} - ${a}`]].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns an empty result (with no fuzzy searches)`, async () => {
expect(await bookFinder.search(null, '', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([])
sinon.assert.callCount(bookFinder.runSearch, 1)
@@ -290,10 +281,7 @@ describe('search', () => {
})
describe('maxFuzzySearches = 1', () => {
[
[`s-01 - ${t} (narrator) 64kbps 10:00:00`],
[`${a} - series 01 - ${t}`],
].forEach(([searchTitle]) => {
;[[`s-01 - ${t} (narrator) 64kbps 10:00:00`], [`${a} - series 01 - ${t}`]].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns an empty result (1 fuzzy search)`, async () => {
expect(await bookFinder.search(null, '', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([])
sinon.assert.callCount(bookFinder.runSearch, 2)
@@ -303,21 +291,13 @@ describe('search', () => {
})
describe('search title contains recognized title and search author is empty', () => {
[
[`${t} - ${a}`],
[`${a} - ${t}`],
].forEach(([searchTitle]) => {
;[[`${t} - ${a}`], [`${a} - ${t}`]].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => {
expect(await bookFinder.search(null, '', searchTitle, '')).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 2)
})
});
[
[`${t}`],
[`${t} - ${u}`],
[`${u} - ${t}`]
].forEach(([searchTitle]) => {
})
;[[`${t}`], [`${t} - ${u}`], [`${u} - ${t}`]].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '') returns an empty result`, async () => {
expect(await bookFinder.search(null, '', searchTitle, '')).to.deep.equal([])
})
@@ -325,19 +305,13 @@ describe('search', () => {
})
describe('search title contains recognized title and search author is an unrecognized author', () => {
[
[`${t} - ${u}`],
[`${u} - ${t}`]
].forEach(([searchTitle]) => {
;[[`${t} - ${u}`], [`${u} - ${t}`]].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => {
expect(await bookFinder.search(null, '', searchTitle, u)).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 2)
})
});
[
[`${t}`]
].forEach(([searchTitle]) => {
})
;[[`${t}`]].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => {
expect(await bookFinder.search(null, '', searchTitle, u)).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 1)
@@ -346,16 +320,19 @@ describe('search', () => {
})
describe('search provider results have duration', () => {
const libraryItem = { media: { duration: 60 * 1000 } }
const libraryItem = { media: { duration: 60 * 1000 } }
const provider = 'audible'
const unsorted = [{ duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }]
const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }]
runSearchStub.withArgs(t, a, provider).resolves(unsorted)
beforeEach(() => {
runSearchStub.withArgs(t, a, provider).resolves(unsorted)
})
it('returns results sorted by library item duration diff', async () => {
expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted)
})
it('returns unsorted results if library item is null', async () => {
expect(await bookFinder.search(null, provider, t, a)).to.deep.equal(unsorted)
})
@@ -365,10 +342,10 @@ describe('search', () => {
})
it('returns unsorted results if library item media is undefined', async () => {
expect(await bookFinder.search({ }, provider, t, a)).to.deep.equal(unsorted)
expect(await bookFinder.search({}, provider, t, a)).to.deep.equal(unsorted)
})
it ('should return a result last if it has no duration', async () => {
it('should return a result last if it has no duration', async () => {
const unsorted = [{}, { duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }]
const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }, {}]
runSearchStub.withArgs(t, a, provider).resolves(unsorted)

View File

@@ -0,0 +1,503 @@
const { expect } = require('chai')
const sinon = require('sinon')
const { Sequelize } = require('sequelize')
const fs = require('../../../server/libs/fsExtra')
const Logger = require('../../../server/Logger')
const MigrationManager = require('../../../server/managers/MigrationManager')
const path = require('path')
const { Umzug, memoryStorage } = require('../../../server/libs/umzug')
describe('MigrationManager', () => {
let sequelizeStub
let umzugStub
let migrationManager
let loggerInfoStub
let loggerErrorStub
let fsCopyStub
let fsMoveStub
let fsRemoveStub
let fsEnsureDirStub
let processExitStub
let configPath = '/path/to/config'
const serverVersion = '1.2.0'
beforeEach(() => {
sequelizeStub = sinon.createStubInstance(Sequelize)
umzugStub = {
migrations: sinon.stub(),
executed: sinon.stub(),
up: sinon.stub(),
down: sinon.stub()
}
sequelizeStub.getQueryInterface.returns({})
migrationManager = new MigrationManager(sequelizeStub, configPath)
migrationManager.fetchVersionsFromDatabase = sinon.stub().resolves()
migrationManager.copyMigrationsToConfigDir = sinon.stub().resolves()
migrationManager.updateMaxVersion = sinon.stub().resolves()
migrationManager.initUmzug = sinon.stub()
migrationManager.umzug = umzugStub
loggerInfoStub = sinon.stub(Logger, 'info')
loggerErrorStub = sinon.stub(Logger, 'error')
fsCopyStub = sinon.stub(fs, 'copy').resolves()
fsMoveStub = sinon.stub(fs, 'move').resolves()
fsRemoveStub = sinon.stub(fs, 'remove').resolves()
fsEnsureDirStub = sinon.stub(fs, 'ensureDir').resolves()
fsPathExistsStub = sinon.stub(fs, 'pathExists').resolves(true)
processExitStub = sinon.stub(process, 'exit')
})
afterEach(() => {
sinon.restore()
})
describe('init', () => {
it('should initialize the MigrationManager', async () => {
// arrange
migrationManager.databaseVersion = '1.1.0'
migrationManager.maxVersion = '1.1.0'
migrationManager.umzug = null
migrationManager.configPath = __dirname
// Act
await migrationManager.init(serverVersion)
// Assert
expect(migrationManager.serverVersion).to.equal(serverVersion)
expect(migrationManager.sequelize).to.equal(sequelizeStub)
expect(migrationManager.migrationsDir).to.equal(path.join(__dirname, 'migrations'))
expect(migrationManager.copyMigrationsToConfigDir.calledOnce).to.be.true
expect(migrationManager.updateMaxVersion.calledOnce).to.be.true
expect(migrationManager.initialized).to.be.true
})
it('should throw error if serverVersion is not provided', async () => {
// Act
try {
const result = await migrationManager.init()
expect.fail('Expected init to throw an error, but it did not.')
} catch (error) {
expect(error.message).to.equal('Invalid server version: undefined. Expected a version tag like v1.2.3.')
}
})
})
describe('runMigrations', () => {
it('should run up migrations successfully', async () => {
// Arrange
migrationManager.databaseVersion = '1.1.0'
migrationManager.maxVersion = '1.1.0'
migrationManager.serverVersion = '1.2.0'
migrationManager.initialized = true
umzugStub.migrations.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }])
umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }])
// Act
await migrationManager.runMigrations()
// Assert
expect(migrationManager.initUmzug.calledOnce).to.be.true
expect(umzugStub.up.calledOnce).to.be.true
expect(umzugStub.up.calledWith({ migrations: ['v1.1.1-migration.js', 'v1.2.0-migration.js'], rerun: 'ALLOW' })).to.be.true
expect(fsCopyStub.calledOnce).to.be.true
expect(fsCopyStub.calledWith(path.join(configPath, 'absdatabase.sqlite'), path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true
expect(fsRemoveStub.calledOnce).to.be.true
expect(fsRemoveStub.calledWith(path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true
expect(loggerInfoStub.calledWith(sinon.match('Migrations successfully applied'))).to.be.true
})
it('should run down migrations successfully', async () => {
// Arrange
migrationManager.databaseVersion = '1.2.0'
migrationManager.maxVersion = '1.2.0'
migrationManager.serverVersion = '1.1.0'
migrationManager.initialized = true
umzugStub.migrations.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }])
umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }, { name: 'v1.1.1-migration.js' }, { name: 'v1.2.0-migration.js' }])
// Act
await migrationManager.runMigrations()
// Assert
expect(migrationManager.initUmzug.calledOnce).to.be.true
expect(umzugStub.down.calledOnce).to.be.true
expect(umzugStub.down.calledWith({ migrations: ['v1.2.0-migration.js', 'v1.1.1-migration.js'], rerun: 'ALLOW' })).to.be.true
expect(fsCopyStub.calledOnce).to.be.true
expect(fsCopyStub.calledWith(path.join(configPath, 'absdatabase.sqlite'), path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true
expect(fsRemoveStub.calledOnce).to.be.true
expect(fsRemoveStub.calledWith(path.join(configPath, 'absdatabase.backup.sqlite'))).to.be.true
expect(loggerInfoStub.calledWith(sinon.match('Migrations successfully applied'))).to.be.true
})
it('should log that no migrations are needed if serverVersion equals databaseVersion', async () => {
// Arrange
migrationManager.serverVersion = '1.2.0'
migrationManager.databaseVersion = '1.2.0'
migrationManager.maxVersion = '1.2.0'
migrationManager.initialized = true
// Act
await migrationManager.runMigrations()
// Assert
expect(umzugStub.up.called).to.be.false
expect(loggerInfoStub.calledWith(sinon.match('Database is already up to date.'))).to.be.true
})
it('should handle migration failure and restore the original database', async () => {
// Arrange
migrationManager.serverVersion = '1.2.0'
migrationManager.databaseVersion = '1.1.0'
migrationManager.maxVersion = '1.1.0'
migrationManager.initialized = true
umzugStub.migrations.resolves([{ name: 'v1.2.0-migration.js' }])
umzugStub.executed.resolves([{ name: 'v1.1.0-migration.js' }])
umzugStub.up.rejects(new Error('Migration failed'))
const originalDbPath = path.join(configPath, 'absdatabase.sqlite')
const backupDbPath = path.join(configPath, 'absdatabase.backup.sqlite')
// Act
await migrationManager.runMigrations()
// Assert
expect(migrationManager.initUmzug.calledOnce).to.be.true
expect(umzugStub.up.calledOnce).to.be.true
expect(loggerErrorStub.calledWith(sinon.match('Migration failed'))).to.be.true
expect(fsMoveStub.calledWith(originalDbPath, sinon.match('absdatabase.failed.sqlite'), { overwrite: true })).to.be.true
expect(fsMoveStub.calledWith(backupDbPath, originalDbPath, { overwrite: true })).to.be.true
expect(loggerInfoStub.calledWith(sinon.match('Restored the original database'))).to.be.true
expect(processExitStub.calledOnce).to.be.true
})
})
describe('fetchVersionsFromDatabase', () => {
it('should fetch versions from the migrationsMeta table', async () => {
// Arrange
const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
// Create a migrationsMeta table and populate it with version and maxVersion
await sequelize.query('CREATE TABLE migrationsMeta (key VARCHAR(255), value VARCHAR(255))')
await sequelize.query("INSERT INTO migrationsMeta (key, value) VALUES ('version', '1.1.0'), ('maxVersion', '1.1.0')")
const migrationManager = new MigrationManager(sequelize, configPath)
migrationManager.checkOrCreateMigrationsMetaTable = sinon.stub().resolves()
// Act
await migrationManager.fetchVersionsFromDatabase()
// Assert
expect(migrationManager.maxVersion).to.equal('1.1.0')
expect(migrationManager.databaseVersion).to.equal('1.1.0')
})
it('should create the migrationsMeta table if it does not exist and fetch versions from it', async () => {
// Arrange
const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
const migrationManager = new MigrationManager(sequelize, configPath)
migrationManager.serverVersion = serverVersion
// Act
await migrationManager.fetchVersionsFromDatabase()
// Assert
const tableDescription = await sequelize.getQueryInterface().describeTable('migrationsMeta')
expect(tableDescription).to.deep.equal({
key: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false },
value: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false }
})
expect(migrationManager.maxVersion).to.equal('0.0.0')
expect(migrationManager.databaseVersion).to.equal('0.0.0')
})
it('should throw an error if the database query fails', async () => {
// Arrange
const sequelizeStub = sinon.createStubInstance(Sequelize)
sequelizeStub.query.rejects(new Error('Database query failed'))
const migrationManager = new MigrationManager(sequelizeStub, configPath)
migrationManager.checkOrCreateMigrationsMetaTable = sinon.stub().resolves()
// Act
try {
await migrationManager.fetchVersionsFromDatabase()
expect.fail('Expected fetchVersionsFromDatabase to throw an error, but it did not.')
} catch (error) {
// Assert
expect(error.message).to.equal('Database query failed')
}
})
})
describe('updateMaxVersion', () => {
it('should update the maxVersion in the database', async () => {
// Arrange
const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
// Create a migrationsMeta table and populate it with version and maxVersion
await sequelize.query('CREATE TABLE migrationsMeta (key VARCHAR(255), value VARCHAR(255))')
await sequelize.query("INSERT INTO migrationsMeta (key, value) VALUES ('version', '1.1.0'), ('maxVersion', '1.1.0')")
const migrationManager = new MigrationManager(sequelize, configPath)
migrationManager.serverVersion = '1.2.0'
// Act
await migrationManager.updateMaxVersion()
// Assert
const [{ maxVersion }] = await sequelize.query("SELECT value AS maxVersion FROM migrationsMeta WHERE key = 'maxVersion'", {
type: Sequelize.QueryTypes.SELECT
})
expect(maxVersion).to.equal('1.2.0')
})
})
describe('extractVersionFromTag', () => {
it('should return null if tag is not provided', () => {
// Arrange
const migrationManager = new MigrationManager(sequelizeStub, configPath)
// Act
const result = migrationManager.extractVersionFromTag()
// Assert
expect(result).to.be.null
})
it('should return null if tag does not match the version format', () => {
// Arrange
const migrationManager = new MigrationManager(sequelizeStub, configPath)
const tag = 'invalid-tag'
// Act
const result = migrationManager.extractVersionFromTag(tag)
// Assert
expect(result).to.be.null
})
it('should extract the version from the tag', () => {
// Arrange
const migrationManager = new MigrationManager(sequelizeStub, configPath)
const tag = 'v1.2.3'
// Act
const result = migrationManager.extractVersionFromTag(tag)
// Assert
expect(result).to.equal('1.2.3')
})
})
describe('copyMigrationsToConfigDir', () => {
it('should copy migrations to the config directory', async () => {
// Arrange
const migrationManager = new MigrationManager(sequelizeStub, configPath)
migrationManager.migrationsDir = path.join(configPath, 'migrations')
const migrationsSourceDir = path.join(__dirname, '..', '..', '..', 'server', 'migrations')
const targetDir = migrationManager.migrationsDir
const files = ['migration1.js', 'migration2.js', 'readme.md']
const readdirStub = sinon.stub(fs, 'readdir').resolves(files)
// Act
await migrationManager.copyMigrationsToConfigDir()
// Assert
expect(fsEnsureDirStub.calledOnce).to.be.true
expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true
expect(readdirStub.calledOnce).to.be.true
expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true
expect(fsCopyStub.calledTwice).to.be.true
expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration1.js'), path.join(targetDir, 'migration1.js'))).to.be.true
expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration2.js'), path.join(targetDir, 'migration2.js'))).to.be.true
})
it('should throw an error if copying the migrations fails', async () => {
// Arrange
const migrationManager = new MigrationManager(sequelizeStub, configPath)
migrationManager.migrationsDir = path.join(configPath, 'migrations')
const migrationsSourceDir = path.join(__dirname, '..', '..', '..', 'server', 'migrations')
const targetDir = migrationManager.migrationsDir
const files = ['migration1.js', 'migration2.js', 'readme.md']
const readdirStub = sinon.stub(fs, 'readdir').resolves(files)
fsCopyStub.restore()
fsCopyStub = sinon.stub(fs, 'copy').rejects()
// Act
try {
// Act
await migrationManager.copyMigrationsToConfigDir()
expect.fail('Expected copyMigrationsToConfigDir to throw an error, but it did not.')
} catch (error) {}
// Assert
expect(fsEnsureDirStub.calledOnce).to.be.true
expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true
expect(readdirStub.calledOnce).to.be.true
expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true
expect(fsCopyStub.calledTwice).to.be.true
expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration1.js'), path.join(targetDir, 'migration1.js'))).to.be.true
expect(fsCopyStub.calledWith(path.join(migrationsSourceDir, 'migration2.js'), path.join(targetDir, 'migration2.js'))).to.be.true
})
})
describe('findMigrationsToRun', () => {
it('should return migrations to run when direction is "up"', () => {
// Arrange
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
const executedMigrations = ['v1.0.0-migration.js']
migrationManager.databaseVersion = '1.0.0'
migrationManager.serverVersion = '1.2.0'
const direction = 'up'
// Act
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
// Assert
expect(result).to.deep.equal(['v1.1.0-migration.js', 'v1.2.0-migration.js'])
})
it('should return migrations to run when direction is "down"', () => {
// Arrange
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
const executedMigrations = ['v1.2.0-migration.js', 'v1.3.0-migration.js']
migrationManager.databaseVersion = '1.3.0'
migrationManager.serverVersion = '1.2.0'
const direction = 'down'
// Act
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
// Assert
expect(result).to.deep.equal(['v1.3.0-migration.js'])
})
it('should return empty array when no migrations to run up', () => {
// Arrange
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js']
migrationManager.databaseVersion = '1.3.0'
migrationManager.serverVersion = '1.4.0'
const direction = 'up'
// Act
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
// Assert
expect(result).to.deep.equal([])
})
it('should return empty array when no migrations to run down', () => {
// Arrange
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
const executedMigrations = []
migrationManager.databaseVersion = '1.4.0'
migrationManager.serverVersion = '1.3.0'
const direction = 'down'
// Act
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
// Assert
expect(result).to.deep.equal([])
})
it('should return down migrations to run when direction is "down" and up migration was not executed', () => {
// Arrange
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
const executedMigrations = []
migrationManager.databaseVersion = '1.3.0'
migrationManager.serverVersion = '1.0.0'
const direction = 'down'
// Act
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
// Assert
expect(result).to.deep.equal(['v1.3.0-migration.js', 'v1.2.0-migration.js', 'v1.1.0-migration.js'])
})
it('should return empty array when direction is "down" and server version is higher than database version', () => {
// Arrange
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js']
migrationManager.databaseVersion = '1.0.0'
migrationManager.serverVersion = '1.3.0'
const direction = 'down'
// Act
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
// Assert
expect(result).to.deep.equal([])
})
it('should return empty array when direction is "up" and server version is lower than database version', () => {
// Arrange
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.3.0-migration.js']
migrationManager.databaseVersion = '1.3.0'
migrationManager.serverVersion = '1.0.0'
const direction = 'up'
// Act
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
// Assert
expect(result).to.deep.equal([])
})
it('should return up migrations to run when server version is between migrations', () => {
// Arrange
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js']
migrationManager.databaseVersion = '1.1.0'
migrationManager.serverVersion = '1.2.3'
const direction = 'up'
// Act
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
// Assert
expect(result).to.deep.equal(['v1.2.0-migration.js'])
})
it('should return down migrations to run when server version is between migrations', () => {
// Arrange
const migrations = [{ name: 'v1.0.0-migration.js' }, { name: 'v1.1.0-migration.js' }, { name: 'v1.2.0-migration.js' }, { name: 'v1.3.0-migration.js' }]
const executedMigrations = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js']
migrationManager.databaseVersion = '1.2.0'
migrationManager.serverVersion = '1.1.3'
const direction = 'down'
// Act
const result = migrationManager.findMigrationsToRun(migrations, executedMigrations, direction)
// Assert
expect(result).to.deep.equal(['v1.2.0-migration.js'])
})
})
describe('initUmzug', () => {
it('should initialize the umzug instance with migrations in the proper order', async () => {
// Arrange
const readdirStub = sinon.stub(fs, 'readdir').resolves(['v1.0.0-migration.js', 'v1.10.0-migration.js', 'v1.2.0-migration.js', 'v1.1.0-migration.js'])
const readFileSyncStub = sinon.stub(fs, 'readFileSync').returns('module.exports = { up: () => {}, down: () => {} }')
const umzugStorage = memoryStorage()
migrationManager = new MigrationManager(sequelizeStub, configPath)
migrationManager.migrationsDir = path.join(configPath, 'migrations')
const resolvedMigrationNames = ['v1.0.0-migration.js', 'v1.1.0-migration.js', 'v1.2.0-migration.js', 'v1.10.0-migration.js']
const resolvedMigrationPaths = resolvedMigrationNames.map((name) => path.resolve(path.join(migrationManager.migrationsDir, name)))
// Act
await migrationManager.initUmzug(umzugStorage)
// Assert
expect(readdirStub.calledOnce).to.be.true
expect(migrationManager.umzug).to.be.an.instanceOf(Umzug)
const migrations = await migrationManager.umzug.migrations()
expect(migrations.map((m) => m.name)).to.deep.equal(resolvedMigrationNames)
expect(migrations.map((m) => m.path)).to.deep.equal(resolvedMigrationPaths)
})
})
})

View File

@@ -0,0 +1,9 @@
async function up() {
console.log('v1.0.0 up')
}
async function down() {
console.log('v1.0.0 down')
}
module.exports = { up, down }

View File

@@ -0,0 +1,9 @@
async function up() {
console.log('v1.1.0 up')
}
async function down() {
console.log('v1.1.0 down')
}
module.exports = { up, down }

View File

@@ -0,0 +1,9 @@
async function up() {
console.log('v1.10.0 up')
}
async function down() {
console.log('v1.10.0 down')
}
module.exports = { up, down }

View File

@@ -0,0 +1,9 @@
async function up() {
console.log('v1.2.0 up')
}
async function down() {
console.log('v1.2.0 down')
}
module.exports = { up, down }

View File

@@ -0,0 +1,50 @@
const { DataTypes } = require('sequelize')
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
/**
* This is an example of an upward migration script.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
logger.info('Running migration_example up...')
logger.info('Creating example_table...')
await queryInterface.createTable('example_table', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: false
}
})
logger.info('example_table created.')
logger.info('migration_example up complete.')
}
/**
* This is an example of a downward migration script.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
logger.info('Running migration_example down...')
logger.info('Dropping example_table...')
await queryInterface.dropTable('example_table')
logger.info('example_table dropped.')
logger.info('migration_example down complete.')
}
module.exports = { up, down }

View File

@@ -0,0 +1,53 @@
const { expect } = require('chai')
const sinon = require('sinon')
const { up, down } = require('./v0.0.1-migration_example')
const { Sequelize } = require('sequelize')
const Logger = require('../../../server/Logger')
describe('migration_example', () => {
let sequelize
let queryInterface
let loggerInfoStub
beforeEach(() => {
sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
queryInterface = sequelize.getQueryInterface()
loggerInfoStub = sinon.stub(Logger, 'info')
})
afterEach(() => {
sinon.restore()
})
describe('up', () => {
it('should create example_table', async () => {
await up({ context: { queryInterface, logger: Logger } })
expect(loggerInfoStub.callCount).to.equal(4)
expect(loggerInfoStub.getCall(0).calledWith(sinon.match('Running migration_example up...'))).to.be.true
expect(loggerInfoStub.getCall(1).calledWith(sinon.match('Creating example_table...'))).to.be.true
expect(loggerInfoStub.getCall(2).calledWith(sinon.match('example_table created.'))).to.be.true
expect(loggerInfoStub.getCall(3).calledWith(sinon.match('migration_example up complete.'))).to.be.true
expect(await queryInterface.showAllTables()).to.include('example_table')
const tableDescription = await queryInterface.describeTable('example_table')
expect(tableDescription).to.deep.equal({
id: { type: 'INTEGER', allowNull: true, defaultValue: undefined, primaryKey: true, unique: false },
name: { type: 'VARCHAR(255)', allowNull: false, defaultValue: undefined, primaryKey: false, unique: false }
})
})
})
describe('down', () => {
it('should drop example_table', async () => {
await up({ context: { queryInterface, logger: Logger } })
await down({ context: { queryInterface, logger: Logger } })
expect(loggerInfoStub.callCount).to.equal(8)
expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Running migration_example down...'))).to.be.true
expect(loggerInfoStub.getCall(5).calledWith(sinon.match('Dropping example_table...'))).to.be.true
expect(loggerInfoStub.getCall(6).calledWith(sinon.match('example_table dropped.'))).to.be.true
expect(loggerInfoStub.getCall(7).calledWith(sinon.match('migration_example down complete.'))).to.be.true
expect(await queryInterface.showAllTables()).not.to.include('example_table')
})
})
})