Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a Migration system and fix #172 #184

Merged
merged 17 commits into from
Feb 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/develop/en/migrations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# What are data migrations for?

Sometimes one may need to perform a concrete operation on every Document, that is, every actor or every item of a type. For instance, there are some cases in which the `template.json` needs to be updated, such as when adding/removing/renaming a property to a Document's (that is, an actor or item) data. However, Foundry might not implement automatically those changes as one needs:
- When creating a new property, Foundry automatically adds the property to all existing documents of the chosen type, setting its value to the default value indicated on the `template.json`. However, it is not rare that we might want to calculate the value of the new property form some other data in the document.
- When deleting a property, Foundry will remove it from documents but it is possible that we want to keep that value inside a different property or use it to transform another property.
- Renaming a property can be seen as a mix of the two previous examples: first, one wants to add a *new* property (the renamed one) inside which one saves the value of the old-named property (i.e., one *calculates* the value of the new property from the value of the old one). Once the value is saved on the new property, one wants to delete the old-named property.

For all this cases (and maybe more), data migrations are used. A data migration is just a mass update of Documents (of a type) to perform a transformation on the data they contain. There are several strategies one could use for implementing such a task (see e.g. [DnD](https://github.com/foundryvtt/dnd5e/blob/master/module/migration.mjs) or [Pathfinder](https://github.com/foundryvtt/pf2e/tree/be77d68bf011a6a4de40c44068a146579c73b4ff/src/module/migration) systems; see also this [YouTube](https://www.youtube.com/watch?v=Hl23n3MvtaI&t) video for a comprehensive discussion on the topic).


# Our migration model

> [!NOTE]
> After this section, explaining how our migration model works, there is a short outline detailing the steps one must follow to add a new migration.

We use a strategy inspired on that of Pathfinder system, simpler and adapted to our needs. Each migration must have an integer version number which will be used to keep track of the already applied migrations and is to be specified as an object implementing the [`Migration`](/src/module/migration/migrations/Migration.d.ts) interface.

The whole system is implemented inside `/src/module/migration/migrate.js`. This module exports a function `applyMigrations()` which is called inside `Hooks.once('ready', ...)` in `/src/animabf.mjs`. A particular migration must be implemented on a module inside the `/src/module/migration/migrations/` folder whose name must start by a number followed by a meaningful description of the migration's purpose. Each migration module must export an object implementing the interface `Migration` defined inside `/src/module/migration/migrations/Migration.d.ts`, where there is documentation on the migration's elements.

Finally, `/src/module/migration/migrations/index.js` allows using the `/src/module/migrations` module as a migration list, since it exports every migration in the system.

# How to add a new migration

1. Create a new migration file inside `/src/module/migration/migrations`. Its name should start by the migration number and be self-explaining; something like `42-purpose-of-this-migration.js`.
2. Inside that file, write and export the migration object, implementing the transformations required for the migration.
3. Export the migration object from the `/src/module/migration/migrations/index.js`.
1 change: 1 addition & 0 deletions docs/develop/es/es.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,5 @@ b) Para continuar tu trabajo o el trabajo de otro: Lo mismo que lo anterior pero

- [Cómo publicar una nueva versión del sistema](publish-new-version.md)
- [Cómo crear un nuevo tipo de item](add-new-item.md)
- [Cómo crear una migración de datos](./migrations.md)
- [Test en Cypress](cypress_integration_tests.md)
27 changes: 27 additions & 0 deletions docs/develop/es/migrations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Para qué sirven las migraciones?

En ocasiones, es necesario realizar operaciones concretas en cada Documento (es decir, en cada actor o cada item de un tipo). Por ejemplo, hay algunos casos en los que el archivo `template.json` necesita actualizarse, como cuando se añade/elimina/renombra una propiedad en los datos de un Documento (i.e., un actor o item). Sin embargo, Foundry en general no implementará los cambios deseados automáticamente:
- Al crear una propiedad nueva, Foundry la crea automáticamente en todos los Documentos existentes del tipo correspondiente, estableciendo como valor para la misma el valor *por defecto* que se indica en el `template.json`. Sin embargo, no es raro calcular el nuevo valor en función de otras propiedades en el documento.
- Al borrar una propiedad, Foundry la eliminará de los documentos pero es posible que queramos mantener el valor correspondiente dentro de otra propiedad, ya sea tal cual estaba o transformándolo en el proceso.
- Renombrar una propiedad puede verse como una concatenación de los dos ejemplos anteriores: primero, queremos añadir una *nueva* propiedad (la que tiene el nuevo nombre), en la cual guardaremos el valor de la propiedad *vieja* (la que tiene el nombre anterior). Esto es, se *calcula* el valor de la nueva propiedad en función de la propiedad anterior. Una vez el valor está a salvo en la nueva propiedad, podemos eliminar la propiedad antigua.

Para todos estos casos (y probablemente más), se usan las migraciones de datos. Una migración de datos es simplemente una actualización masiva de Documentos (de un tipo dado) para realizar una transformación de los datos que contienen. Hay distintas estrategias que podrían usarse para implementar esta tarea (ver, por ejemplo, los sistemas para [DnD](https://github.com/foundryvtt/dnd5e/blob/master/module/migration.mjs) o [Pathfinder](https://github.com/foundryvtt/pf2e/tree/be77d68bf011a6a4de40c44068a146579c73b4ff/src/module/migration); también este [vídeo de Youtube](https://www.youtube.com/watch?v=Hl23n3MvtaI&t) para una discusión clara del tema).


# Nuestro modelo de migraciones

> [!NOTE]
> Tras esta sección, que explica cómo funciona nuestro modelo de migraciones, hay un esquema detallando los pasos a seguir para añadir una nueva migración al sistema.

Usamos una estrategia inspirada en la que usan en el sistema de Pathfinder, simplificada y adaptada a nuestras necesidades. Cada migración debe tener un número (entero) de versión, que se usará para tener en cuenta qué migraciones ya han sido aplicadas y cuales no. Cada migración se especificará en un objeto que implementará la interfaz [`Migration`](/src/module/migration/migrations/Migration.d.ts).

Todo el sistema de migraciones está implementado en [`/src/module/migration/migrate.js`](/src/module/migration/migrate.js). Dicho módulo exporta una función `applyMigrations()` que es llamada dentro de `Hooks.once('ready', ...)` en [`/src/animabf.mjs`](/src/animabf.mjs). Cada migración concreta debe ser implementada en un módulo dentro de la carpeta `/src/module/migration/migrations/`, cuyo nombre debe comenzar por un número entero seguido de un nombre que describa el propósito de la migración. Cada módulo de migración debe exportar un objeto implementando la interfaz `Migration` definida en [`/src/module/migration/migrations/Migration.d.ts`](/src/module/migration/migrations/Migration.d.ts) (allí se encuentran documentados los elementos que debe contener dicho objeto).

Finalmente, [`/src/module/migration/migrations/index.js`](/src/module/migration/migrations/index.js) permite usar el módulo `/src/module/migrations` como una lista de migraciones, dado que debe exportar todas las migraciones del sistema.


# Cómo añadir una migración nueva

1. Crear un nuevo archivo de migración dentro de `/src/module/migration/migrations`. El nombre debe empezar por el número de la migración y ser autoexplicativo; algo como `42-purpose-of-this-migration.js`.
2. Dentro de ese archivo, definir y exportar el objeto de la de la migración, que implementará las transformaciones requeridas para la migración de datos.
3. Exportar el archivo de migración desde el archivo `/src/module/migration/migrations/index.js`.
26 changes: 26 additions & 0 deletions docs/develop/fr/migrations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# A quoi servent les migrations ?

Parfois il est nécessaire de réaliser des opérations concrètes sur chaque Document, c'est à dire chaque actor ou chaque item d'un type donné. Par exemple, il y a certains cas où il est nécessaire de mettre à jour le fichier `template.json`, comme lorsque l'on ajoute/supprime/renomme une propriété aux données d'un Document (i.e, un actor ou item). Cependant, en général, Foundry n'implémentera pas automatiquement les changements souhaités :
- A la création d'une nouvelle propriété, Foundry l'ajoute automatiquement à tous les documents d'un type donné, définissant sa valeur à la valeur par défaut indiquée dans le `template.json`. Cependant, il n'est pas rare que nous souhaitions calculer la valeur de cette nouvelle propriété en fonction d'autres propriétés dans le document.
- A la suppression d'une propriété, Foundry va la retirer des documents mais il est possible que nous souhaitions conserver cette valeur à l'intéreur d'une autre propriété ou l'utiliser pour transformer une autre propriété.
- Renommer une propriété peut se voir comme une combinaison des deux exemples précédents: premièrement, nous voulons ajouter une *nouvelle* propriété (celle avec le nouveau nom) dans laquelle nous stockons la valeur de l'ancienne propriété (celle avec l'ancien nom). Autrement dit, la valeur de la nouvelle propriété est calculée en fonction de l'ancienne propriété. Une fois que la valeur est en sécurité dans la nouvelle propriété, nous pouvons supprimer l'ancienne propriété.

Pour tous ces cas (et probablement d'autres), les migrations de données sont utilisées. Une migration de données est simplement une mise à jour massive de documents (d'un type donné) pour effectuer une transformation des données qu'ils contiennent. Il existe différentes stratégies qui pourraient être utilisées pour mettre en œuvre cette tâche (voir par exemple, les systèmes pour [DnD](https://github.com/foundryvtt/dnd5e/blob/master/module/migration.mjs) ou [Pathfinder](https://github.com/foundryvtt/pf2e/tree/be77d68bf011a6a4de40c44068a146579c73b4ff/src/module/migration); voir également la [vidéo YouTube](https://www.youtube.com/watch?v=Hl23n3MvtaI&t) pour une discussion claire sur le sujet.


# Notre modèle de migrations

> [!NOTE]
> Après cette section, expliquant comment notre modèle de migration fonctionne, il y a un court schèma qui détaille les étapes nécessaires requises pour ajouter une nouvelle migration.

Nous utilisons une stratégie inspirée de celle utilisée par le système Pathfinder, plus simple et adaptée à nos besoins. Chaque migration doit avoir un numéro (entier) de version qui sera utilisée pour garder une trace des migrations déjà appliquées et celles qui ne l'ont pas encore été. Chaque migration est spécifiée dans un objet qui implémente l'interface [`Migration`](/src/module/migration/migrations/Migration.d.ts).

Le système entier est implémenté dans `/src/module/migration/migrate.js`. Ce module exporte une fonction `applyMigrations()` qui est appelée par `Hooks.once('ready', ...)` dans le fichier `/src/animabf.mjs`. Une migration spécifique doit être implémentée à l'intérieur du répertoire `/src/module/migration/migrations/` doit commencer par un nombre suivi par une description significative de l'objectif de la migration. Chaque migration doit exporter un objet implémentant l'interface `Migration` définie à l'intérieur de `/src/module/migration/migrations/Migration.d.ts`, où se trouvent les éléments documentant la migration.

Enfin, `/src/module/migration/migrations/index.js` permet d'utiliser le module `/src/module/migrations` comme une liste de migrations, car il doit exporter toutes les migrations du système.

# Comment ajouter une nouvelle migration

1. Créez un nouveau fichier de migrations dans `/src/module/migration/migrations`. Son nom doit commencer par son numéro de migration et s'expliquer par lui-même; comme ci-après `42-purpose-of-this-migration.js`.
2. A l'intérieur de ce fichier, écrivez et exportez l'objet de la migration, implémentant les transformations requises pour la migration des données.
3. Exportez l'objet de la migration à partir du fichier `/src/module/migration/migrations/index.js`.
3 changes: 3 additions & 0 deletions src/animabf.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ABFConfig } from './module/ABFConfig';
import ABFItem from './module/items/ABFItem';
import { registerCombatWebsocketRoutes } from './module/combat/websocket/registerCombatWebsocketRoutes';
import { attachCustomMacroBar } from './utils/attachCustomMacroBar';
import { applyMigrations } from './module/migration/migrate';

/* ------------------------------------ */
/* Initialize system */
Expand Down Expand Up @@ -67,6 +68,8 @@ Hooks.once('ready', () => {
registerCombatWebsocketRoutes();

attachCustomMacroBar();

applyMigrations();
});

// Add any additional hooks if necessary
Expand Down
4 changes: 4 additions & 0 deletions src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,10 @@
"dialogs.accept": "Accept",
"dialogs.cancel": "Cancel",
"dialogs.continue": "Continue",
"dialogs.migrations.title": "Migration available",
"dialogs.migrations.content": "A migration is about to start. To prevent data loss, please back up your data folder befor applying the migration. Are you ready to continue?",
"dialogs.migrations.success": "Migration #{version} <em>\"{title}\"</em> has been successfully applied.",
"dialogs.migrations.error": "Migration #{version} has failed to apply:<br>{error}",
"dialogs.items.advantage.content": "Advantage name",
"dialogs.items.ammo.content": "Ammo name",
"dialogs.items.armors.content": "Armor name",
Expand Down
4 changes: 4 additions & 0 deletions src/lang/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,10 @@
"dialogs.accept": "Aceptar",
"dialogs.cancel": "Cancelar",
"dialogs.continue": "Continuar",
"dialogs.migrations.title": "Migración disponible",
"dialogs.migrations.content": "Está a punto de comenzar una migración. Para evitar pérdidas de datos, haga una copia de seguridad de su carpeta de datos antes de continuar. ¿Desea continuar con la migración?",
"dialogs.migrations.success":"La migración #{version} <em>\"{title}\"</em> se ha aplicado con éxito.",
"dialogs.migrations.error": "La migración #{version} ha fallado con el error:<br>{error}",
"dialogs.items.advantage.content": "Nombre de la ventaja",
"dialogs.items.ammo.content": "Nombre de la munición",
"dialogs.items.armors.content": "Nombre de la armadura",
Expand Down
4 changes: 4 additions & 0 deletions src/lang/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,10 @@
"dialogs.accept": "Accepter",
"dialogs.cancel": "Annuler",
"dialogs.continue": "Continuer",
"dialogs.migrations.title": "Migration available",
"dialogs.migrations.content": "A migration is about to start. To prevent data loss, please back up your data folder befor applying the migration. Are you ready to continue?",
"dialogs.migrations.success": "Migration #{version} <em>\"{title}\"</em> has been successfully applied.",
"dialogs.migrations.error": "Migration #{version} has failed to apply:<br>{error}",
"dialogs.items.advantage.content": "Nom de l'Avantage",
"dialogs.items.ammo.content": "Nom des Munitions",
"dialogs.items.armors.content": "Nom de l'Armure",
Expand Down
2 changes: 1 addition & 1 deletion src/module/actor/ABFActor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class ABFActor extends Actor {
`Upgrading actor ${this.name} (${this._id}) from version ${this.system.version} to ${INITIAL_ACTOR_DATA.version}`
);

this.updateSource({ version: INITIAL_ACTOR_DATA.version });
this.updateSource({ 'system.version': INITIAL_ACTOR_DATA.version });
}
}

Expand Down
1 change: 0 additions & 1 deletion src/module/actor/constants.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/** @type {import("../types/Actor").ABFActorDataSourceData} */
export const INITIAL_ACTOR_DATA = {
version: 1,
ui: {
Expand Down
6 changes: 3 additions & 3 deletions src/module/dialogs/ABFDialogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ export const ABFDialogs = {
body: string,
{ onConfirm, onCancel }: { onConfirm?: () => void; onCancel?: () => void } = {}
) =>
new Promise<void>(resolve => {
new Promise<string>(resolve => {
new ConfirmationDialog(title, body, {
onConfirm: () => {
onConfirm?.();
resolve();
resolve("confirm");
},
onCancel: () => {
onCancel?.();
resolve();
resolve("cancel");
}
});
})
Expand Down
2 changes: 1 addition & 1 deletion src/module/dialogs/ConfirmationDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class ConfirmationDialog extends GenericDialog {
class: 'confirmation-dialog',
content: `
<p class='title'>${title}</p>
<p class='body'>${body}</p>
<div class='body'>${body}</div>
`,
buttons: [
{ id: 'on-cancel-button', fn: onCancel, content: (game as Game).i18n.localize('dialogs.cancel') },
Expand Down
Loading