Files
suwayomi-material-you-webui/src/modules/metadata/services/MetadataMigrations.ts

386 lines
13 KiB
TypeScript
Raw Normal View History

2024-10-05 23:17:20 +02:00
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
2025-01-05 02:15:03 +01:00
import { useEffect } from 'react';
import {
APP_METADATA_KEY_PREFIX,
METADATA_MIGRATIONS,
VALID_APP_METADATA_KEYS,
} from '@/modules/metadata/Metadata.constants.ts';
import {
AppMetadataKeys,
IMetadataMigration,
Metadata,
MetadataHolder,
MetadataHolderType,
MetadataKeyValuePair,
} from '@/modules/metadata/Metadata.types.ts';
import { extractOriginalKey, getMetadataKey } from '@/modules/metadata/Metadata.utils.ts';
2025-01-05 02:15:03 +01:00
import { MangaIdInfo } from '@/modules/manga/Manga.types.ts';
import { CategoryIdInfo } from '@/modules/category/Category.types.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { getMetadataDeleteFunction, getMetadataUpdateFunction } from '@/modules/metadata/services/MetadataUpdater.ts';
2025-04-21 17:00:13 +02:00
import { SourceIdInfo } from '@/modules/source/Source.types.ts';
import { ChapterIdInfo } from '@/modules/chapter/Chapter.types.ts';
2024-10-05 23:17:20 +02:00
2025-01-05 14:54:20 +01:00
const getAppKeyPrefixForMigration = (migrationId: number): string => {
2024-10-05 23:17:20 +02:00
const appKeyPrefix = METADATA_MIGRATIONS.slice(0, migrationId)
.reverse()
.find((migration) => !!migration.appKeyPrefix);
return appKeyPrefix?.appKeyPrefix?.newPrefix ?? APP_METADATA_KEY_PREFIX;
};
const getAppMetadataFrom = (
meta: Metadata,
prefixes: string[] = [],
appPrefix: string = APP_METADATA_KEY_PREFIX,
): Metadata => {
const appMetadata: Metadata = {};
Object.entries(meta).forEach(([key, value]) => {
if (key.startsWith([appPrefix, ...prefixes].join('_'))) {
appMetadata[key] = value;
}
});
return appMetadata;
};
2025-01-05 14:54:20 +01:00
const applyAppKeyPrefixMigration = (meta: Metadata, migration: IMetadataMigration): Metadata => {
2024-10-05 23:17:20 +02:00
const migratedMetadata: Metadata = { ...meta };
if (!migration.appKeyPrefix) {
return migratedMetadata;
}
const { oldPrefix, newPrefix } = migration.appKeyPrefix;
2024-10-28 18:03:29 +01:00
const oldAppMetadata = getAppMetadataFrom(meta, undefined, oldPrefix);
2024-10-05 23:17:20 +02:00
Object.keys(oldAppMetadata).forEach((oldKey) => {
const keyWithNewPrefix = oldKey.replace(oldPrefix, newPrefix);
migratedMetadata[keyWithNewPrefix] = oldAppMetadata[oldKey];
});
2024-10-05 23:17:20 +02:00
return migratedMetadata;
};
const applyMetadataValueMigration = (meta: Metadata, migration: IMetadataMigration, appKeyPrefix: string): Metadata => {
const migratedMetadata: Metadata = { ...meta };
if (!migration.values) {
return migratedMetadata;
}
2024-10-28 18:03:29 +01:00
const appMetadata = getAppMetadataFrom(meta, undefined, appKeyPrefix);
2024-10-05 23:17:20 +02:00
const metadataValueChanges = migration.values;
metadataValueChanges.forEach(({ key, oldValue, newValue }) => {
const migrateValue = (metaKey: string) => {
if (meta[metaKey].match(oldValue)) {
migratedMetadata[metaKey] = typeof newValue === 'function' ? newValue(meta[metaKey]) : newValue;
2024-10-05 23:17:20 +02:00
}
};
const migrateValueOfAllAppKeys = key === undefined;
Object.keys(appMetadata).forEach((metaKey) => {
if (migrateValueOfAllAppKeys || metaKey.endsWith(key)) {
2024-10-05 23:17:20 +02:00
migrateValue(metaKey);
}
});
2024-10-05 23:17:20 +02:00
});
return migratedMetadata;
};
const applyMetadataKeyMigration = (meta: Metadata, migration: IMetadataMigration, appKeyPrefix: string): Metadata => {
2024-10-05 23:17:20 +02:00
const migratedMetadata: Metadata = { ...meta };
if (!migration.keys) {
return migratedMetadata;
}
const appMetadata = getAppMetadataFrom(meta, undefined, appKeyPrefix);
2024-10-05 23:17:20 +02:00
migration.keys.forEach(({ oldKey, newKey }) => {
Object.keys(appMetadata).forEach((key) => {
if (!key.endsWith(oldKey)) {
return;
}
2024-10-05 23:17:20 +02:00
const prefixes = key.split('_');
const prefix = prefixes.slice(0, prefixes.length - 1).join('_');
const newKeyWithOldKeysPrefix = `${prefix}_${newKey}`;
2024-10-05 23:17:20 +02:00
migratedMetadata[newKeyWithOldKeysPrefix] = appMetadata[key];
});
2024-10-05 23:17:20 +02:00
});
return migratedMetadata;
};
const applyMetadataDeleteKeysMigration = (
meta: Metadata,
migration: IMetadataMigration,
appKeyPrefix: string,
): Metadata => {
const migratedMetadata: Metadata = {};
if (!migration.deleteKeys) {
return { ...meta };
}
const appMetadata = getAppMetadataFrom(meta, undefined, appKeyPrefix);
Object.keys(appMetadata).forEach((key) => {
if (migration.deleteKeys?.includes(extractOriginalKey(key))) {
return;
}
migratedMetadata[key] = appMetadata[key];
});
return migratedMetadata;
};
const getOutdatedMetadataKeys = (metadata: Metadata | undefined, migrationId: number): AppMetadataKeys[] => {
2025-01-05 02:15:03 +01:00
if (!metadata) {
return [];
2024-10-05 23:17:20 +02:00
}
const oldAppKeyPrefixes = METADATA_MIGRATIONS.slice(migrationId).reduce((acc, migration) => {
2025-01-05 02:15:03 +01:00
const oldPrefix = migration.appKeyPrefix?.oldPrefix;
if (!oldPrefix) {
return acc;
}
return [...acc, oldPrefix];
}, [] as string[]);
const keyToDeleteInMigrations = METADATA_MIGRATIONS.slice(migrationId).reduce(
(acc, migration) => [...acc, ...(migration.deleteKeys ?? [])],
[] as string[],
);
2025-01-05 02:15:03 +01:00
return Object.keys(metadata).filter((key) => {
const appKeyPrefixOfKey = key.split('_')[0];
const isMetadataKeyOfApp = [...oldAppKeyPrefixes, APP_METADATA_KEY_PREFIX].includes(appKeyPrefixOfKey);
if (!isMetadataKeyOfApp) {
return false;
}
const isOldKeyPrefix = oldAppKeyPrefixes.includes(appKeyPrefixOfKey);
if (isOldKeyPrefix) {
return true;
}
const extractedKey = extractOriginalKey(key);
if (keyToDeleteInMigrations.includes(extractedKey)) {
return true;
}
return !VALID_APP_METADATA_KEYS.includes(extractedKey);
2025-01-05 02:15:03 +01:00
}) as AppMetadataKeys[];
};
const getNewMetadataKeys = (
metadata: Metadata | undefined,
migratedMetadata: Metadata,
metadataKeyToDelete: string[],
migrationId: number,
2025-01-05 02:15:03 +01:00
): string[] => {
if (!metadata) {
return [];
}
const newKeys = METADATA_MIGRATIONS.slice(migrationId).reduce((acc, migration) => {
2025-01-05 02:15:03 +01:00
if (!migration.keys) {
return acc;
}
const keys = migration.keys.map(({ newKey }) => newKey).filter((key) => key !== undefined);
return [...acc, ...keys];
}, [] as string[]);
return Object.keys(migratedMetadata).filter((metadataKey) => {
const key = extractOriginalKey(metadataKey);
const isNewKeyOfAMigration = newKeys.includes(key);
const isOldKeyOfAMigration = metadataKeyToDelete.includes(key);
const isAlreadyCommited = Object.hasOwn(metadata, metadataKey);
return !isAlreadyCommited && isNewKeyOfAMigration && !isOldKeyOfAMigration;
});
};
2025-01-11 03:02:10 +01:00
const getMetadataKeysWithUpdatedValues = (
metadata: Metadata | undefined,
newAndDeletedMetadataKeys: string[],
migrationId: number,
2025-01-11 03:02:10 +01:00
): string[] => {
if (!metadata) {
return [];
}
const keysWithUpdatedValues = METADATA_MIGRATIONS.slice(migrationId).reduce((acc, migration) => {
2025-01-11 03:02:10 +01:00
const keysWithUpdatedValuesOfMigration = Object.keys(metadata).filter((metadataKey) =>
migration.values?.some(({ key: migrationKey, oldValue }) => {
const isMigrationForAllKeys = !migrationKey;
const doesValueMatch = metadata[metadataKey] === oldValue;
if (isMigrationForAllKeys) {
return doesValueMatch;
}
return metadataKey.endsWith(migrationKey) && doesValueMatch;
}),
);
return [...acc, ...keysWithUpdatedValuesOfMigration];
}, [] as string[]);
return [
...new Set([
...keysWithUpdatedValues.filter(
(keyWithUpdatedValue) => !newAndDeletedMetadataKeys.includes(keyWithUpdatedValue),
),
]),
];
};
2025-01-05 02:15:03 +01:00
/**
* Prevent spamming requests due to frequent metadata reads while the migration hasn't been commited to the server yet
*/
const commitedMigrations = new Set<string>();
const commitMigratedMetadata = (
type: MetadataHolderType,
metadataHolder:
| MetadataHolder
| (MangaIdInfo & MetadataHolder)
| (ChapterIdInfo & MetadataHolder)
| (CategoryIdInfo & MetadataHolder)
2025-04-21 17:00:13 +02:00
| (SourceIdInfo & MetadataHolder)
2025-01-05 02:15:03 +01:00
| undefined,
migratedMetadata: Metadata,
useEffectFn: typeof useEffect = (fn: () => void) => fn(),
): void => {
const metadata = metadataHolder?.meta;
const migrationId = Number(metadata?.[getMetadataKey('migration')] ?? 1);
const metadataKeysToDelete = getOutdatedMetadataKeys(metadata, migrationId);
const newMetadataKeys = getNewMetadataKeys(metadata, migratedMetadata, metadataKeysToDelete, migrationId);
const metadataKeysWithUpdatedValues = getMetadataKeysWithUpdatedValues(
metadata,
[...metadataKeysToDelete, ...newMetadataKeys],
migrationId,
);
2025-01-11 03:02:10 +01:00
const metadataToUpdate = [...newMetadataKeys, ...metadataKeysWithUpdatedValues].map((key) => [
key,
migratedMetadata[key],
]) as MetadataKeyValuePair[];
2025-01-05 02:15:03 +01:00
const updateMetadata = getMetadataUpdateFunction(type, metadataHolder ?? { id: -1, meta: {} });
const deleteMetadata = getMetadataDeleteFunction(type, metadataHolder ?? { id: -1, meta: {} });
useEffectFn(() => {
(async () => {
2025-01-11 03:13:38 +01:00
const itemMigrationKey = `${type}_${type === 'global' ? '' : (metadataHolder as { id: any } | undefined)?.id}`;
2025-01-05 02:15:03 +01:00
const commitMigration = !commitedMigrations.has(itemMigrationKey);
if (!commitMigration) {
return;
}
const isMetadataAlreadyMigrated = !metadata || migrationId === METADATA_MIGRATIONS.length;
2025-01-05 02:15:03 +01:00
const isCommitRequired =
!isMetadataAlreadyMigrated && (!!metadataKeysToDelete.length || !!metadataToUpdate.length);
2025-01-05 02:15:03 +01:00
if (!isCommitRequired) {
return;
}
commitedMigrations.add(itemMigrationKey);
try {
await updateMetadata([...metadataToUpdate], undefined, true);
2025-01-05 02:15:03 +01:00
await deleteMetadata(metadataKeysToDelete, undefined, true);
await updateMetadata([['migration', METADATA_MIGRATIONS.length]]);
2025-01-05 02:15:03 +01:00
} catch (e) {
commitedMigrations.delete(itemMigrationKey);
defaultPromiseErrorHandler(
`MetadataMigrations#commitMigrateMetadata(${type}, ${(metadataHolder as { id: any })?.id})`,
2025-03-03 23:59:03 +01:00
)(e);
2025-01-05 02:15:03 +01:00
}
})();
});
};
export const applyMetadataMigrations = (
type: MetadataHolderType,
metadataHolder?:
| MetadataHolder
| (MangaIdInfo & MetadataHolder)
| (ChapterIdInfo & MetadataHolder)
| (CategoryIdInfo & MetadataHolder)
2025-04-21 17:00:13 +02:00
| (SourceIdInfo & MetadataHolder),
2025-01-05 02:15:03 +01:00
useEffectFn: typeof useEffect = (fn: () => void) => fn(),
): Metadata | undefined => {
const meta = { ...(metadataHolder?.meta ?? {}) };
const migrationIdKey = getMetadataKey('migration');
const appliedMigrationId = Number.isNaN(Number(meta[migrationIdKey]))
? 0
: Math.max(0, Number(meta[migrationIdKey]));
2025-01-05 02:15:03 +01:00
const migrationToMetadata: [number, Metadata][] = [[0, meta]];
2024-10-05 23:17:20 +02:00
METADATA_MIGRATIONS.forEach((migration, index) => {
const migrationId = index + 1;
const metadataToMigrate = migrationToMetadata[migrationId - 1][1];
2025-01-05 02:15:03 +01:00
const isMigrationRequired = appliedMigrationId < migrationId;
if (!isMigrationRequired) {
migrationToMetadata.push([migrationId, metadataToMigrate]);
return;
}
const appKeyPrefixForMigration = getAppKeyPrefixForMigration(migrationId);
2024-10-05 23:17:20 +02:00
const appKeyPrefixMigrated = applyAppKeyPrefixMigration(metadataToMigrate, migration);
const metadataValuesMigrated = applyMetadataValueMigration(
appKeyPrefixMigrated,
migration,
appKeyPrefixForMigration,
);
const metadataKeysMigrated = applyMetadataKeyMigration(
metadataValuesMigrated,
migration,
appKeyPrefixForMigration,
2024-10-05 23:17:20 +02:00
);
const metadataKeysDeletedMigrated = applyMetadataDeleteKeysMigration(
metadataKeysMigrated,
migration,
appKeyPrefixForMigration,
);
2024-10-05 23:17:20 +02:00
migrationToMetadata.push([migrationId, metadataKeysDeletedMigrated]);
2024-10-05 23:17:20 +02:00
});
2025-01-05 02:15:03 +01:00
const migratedMetadata = migrationToMetadata.pop()?.[1] ?? meta;
2024-10-05 23:17:20 +02:00
2025-01-05 02:15:03 +01:00
commitMigratedMetadata(type, metadataHolder, migratedMetadata, useEffectFn);
return migratedMetadata;
2024-10-05 23:17:20 +02:00
};