Database
========
The underlying data store of CARE is a relational database. The `PostgresSQL `_ database is
managed and accessed via `Sequelize `_. The backend services and sockets of CARE access the
database via predefined methods provided in with the MetaModel class in ``backend/db`` directory, which in turn utilize Sequelize as an layer
of abstraction to query and update the database.
The database schema is modified via `Sequelize's migration system `_.
``make init`` adds any new migrations to the database. Modifying the database schema therefore means adding a new
migration and potentially a new Sequelize model.
All relevant files can be found in the ``backend/db`` folder.
Models
------
Models are defined in the ``models`` folder.
They are defined using the `Sequelize ORM `_.
The following entity relationship model (ERM) shows the relations between the different models:
.. image:: ./ERM.drawio.svg
:width: 100%
:align: center
Additionally, the following attributes are added to each table:
- ``deleted``: Boolean indicating whether the entry is deleted or not.
- ``createdAt``: The time when the entry was created.
- ``updatedAt``: The time when the entry was last updated.
- ``deletedAt``: The time when the entry was deleted.
Migrations
----------
Working with migrations is done using the `Sequelize CLI `_.
To install the CLI, run ``npm install --save-dev sequelize-cli`` inside of the db folder.
After that, you can run ``npx sequelize --help`` to see all available commands.
To create a new migration, run ``npx sequelize migration:generate --name ``.
This will create a new file in the ``migrations`` folder, containing a ``up`` and a ``down`` function.
Each migration file in CARE follows a consistent structure and naming pattern to ensure reliable execution and easy readability.
**File Name Structure:**
.. code-block:: none
YYYYMMDDHHMMSS-action-target.js
- ``YYYYMMDDHHMMSS``: A UTC timestamp ensuring chronological execution.
- ``action``: What the migration does (e.g., `create`, `extend`, `drop`).
- ``target``: The name of the database table affected by the migration (e.g., user, study, session).
**Example:**
.. code-block:: none
20240411143091-extend-setting-editor.js
The ``action`` part of a migration filename typically falls into one of the following categories:
- **create** — Introduces a new table or model
- **basic** — Inserts default records for a table (e.g., roles, tags, nav items)
- **extend** — Adds new columns or modifies existing tables
- **transform** — Applies structural or content transformations to existing data
- **drop** — Removes obsolete tables or features
Adding a New Model
~~~~~~~~~~~~~~~~~~
1. Use the CLI to create a model and a migration file.
.. code-block:: bash
npx sequelize migration:generate --name -nav
2. In the newly generated migration file under migrations change the table name. It should be
a singular lower-case term.
3. Now you need to add a new model file for this table. Create a new file in ``backend/db/models``
that exports a function for generating the model as an instance of a MetaModel from a given
Sequelize object. This looks as follows for a table called ``simpletable`` (the name introduced in 2.):
.. code-block:: javascript
'use strict';
const MetaModel = require("../MetaModel.js");
module.exports = (sequelize, DataTypes) => {
class SimpleTable extends MetaModel {
static associate(models) {
}
}
SimpleTable.init({
updatedAt: DataTypes.DATE,
deleted: DataTypes.BOOLEAN,
deletedAt: DataTypes.DATE,
createdAt: DataTypes.DATE
}, {
sequelize: sequelize,
modelName: 'simpletable',
tableName: 'simpletable'
});
return SimpleTable;
};
4. Add the foreign keys and constraints to the migration file as desired. For examples check out
the other migrations. For a very simple table ``simpletable`` this would look like this:
.. code-block:: javascript
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('simpletable', {
id: {
allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER
},
deleted: {
type: Sequelize.BOOLEAN, defaultValue: false
},
createdAt: {
allowNull: false, type: Sequelize.DATE
},
updatedAt: {
allowNull: false, type: Sequelize.DATE
},
deletedAt: {
allowNull: true, defaultValue: null, type: Sequelize.DATE
}
});
}, async down(queryInterface, Sequelize) {
await queryInterface.dropTable('simpletable');
}
5. If necessary update the model file accordingly (if fields have been changed). Make sure adding additional columns
like deleted, createdAt, updatedAt and deletedAt is done in the migration file.
Accessing a Model
~~~~~~~~~~~~~~~~~
The above steps are necessary to create a new migration and associated model. To encapsulate
the database interactions, the ``MetaModel`` class already contains several convenience functions for accessing the
respective table. For instance, getting a single row entry by a provided ID (``getById(id)``). Accessing the DB via
these default functions is as simple as accessing the table model and executing the static method.
.. code-block:: javascript
// usually, you just access the models loaded in the web server; for completeness we provide the imports here:
const {DataTypes} = require("sequelize")
const db = require("./db/index.js")
const SimpleTable = require("./db/models/simpletable.js")(db.sequelize, DataTypes);
SimpleTable.getById("x");
.. note::
Generally you should *not* import the db object yourself and load a model as in the provided example. Instead,
the web server object already holds the respective models in a class attribute. Please check out the
:doc:`./socket` for the details.
In case you need more specific functions, you may simply add static access methods to the new model class. For instance:
.. code-block:: javascript
'use strict';
const MetaModel = require("../MetaModel.js");
module.exports = (sequelize, DataTypes) => {
class SimpleTable extends MetaModel {
static associate(models) {
}
// new access function specific to this model
static async getDefaultRow() {
//do error management -- ommitted for brevity
return await this.findOne({ where: {'id': "x"}, raw: true});
}
}
//... initialization etc.
};
Populating a Table
~~~~~~~~~~~~~~~~~~
To populate a table with basic data rows, you can use seeds to test and debug and then
convert it into a regular migration.
1. Create a seeder
.. code-block:: bash
npx sequelize-cli seed:generate --name
2. Adapt the seeder in the respective file under directory "seeders".
3. Move it to the migrations folder.
4. Double check that you have set the down method accordingly -- you don't want
to drop an entire table if you only add a few rows. Delete just these rows.
Database Hooks
--------------
Definition and Purpose
~~~~~~~~~~~~~~~~~~~~~~
Sequelize models support lifecycle hooks, which are special functions that run **automatically** at key points during database operations — such as after a row is created, updated, or deleted.
.. note::
For a full reference on available hooks and usage, see the official Sequelize documentation: `Sequelize Hook Documentation `_.
Hooks improve **data consistency** and **encapsulation** by allowing you to:
- Assign related data after creation
- Cascade deletions or cleanup logic
- Trigger logic in response to state changes (e.g., toggling a `public` or `deleted` flag)
This ensures that important side effects happen reliably regardless of where the database call originates.
Usage of `afterCreate` and `afterUpdate`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The most common lifecycle hooks used in CARE are:
- ``afterCreate`` — runs after a new entry is added to the database
- ``afterUpdate`` — runs after an existing entry is updated
These hooks receive the **model instance** and the **options** object as arguments. The `options` object may include useful context or the active database transaction.
.. code-block:: javascript
hooks: {
afterCreate: async (instance, options) => {
const { transaction, context } = options;
// e.g., assign roles after user creation
},
afterUpdate: async (instance, options) => {
const { transaction } = options;
// e.g., trigger logic if a specific flag was changed
}
}
Cascade Logic Example
~~~~~~~~~~~~~~~~~~~~~
A common pattern is to perform **cascade deletion**: when a parent item is soft-deleted, its related children should be removed as well.
You can use the `afterUpdate` hook to detect when the `deleted` flag changes, and trigger cleanup accordingly:
.. code-block:: javascript
afterUpdate: async (instance, options) => {
if (instance.deleted && instance._previousDataValues.deleted === false) {
await SomeModel.deleteChildren(instance.id, {
transaction: options.transaction
});
}
}
.. note::
By comparing the current value (`instance.deleted`) to the previous one (`instance._previousDataValues.deleted`), you ensure the logic only runs when the value actually changes.
Handling `individualHooks`
~~~~~~~~~~~~~~~~~~~~~~~~~~
When using `Model.update(...)`, Sequelize **does not run per-row hooks** like `afterUpdate` by default. It only runs them once for the entire bulk update.
To ensure hooks are triggered **for every updated row**, you must enable:
.. code-block:: javascript
individualHooks: true
Example use inside a model update utility:
.. code-block:: javascript
const options = {
where: { id },
transaction,
individualHooks: true
};
await this.update(data, options);
.. note::
While this behavior is important to be aware of, it's not something we've encountered often in practice. In most cases, bulk updates work as expected without needing `individualHooks`.