Transactions

In Node.js, Nymph supports nested transactions. Nothing will be permanently written to the database's storage, and no PubSub subscribers will be notified, until the highest level transaction is committed. When a nested transaction is rolled back, the database will be in the state where the transaction above it started that nested transaction.

When you start a transaction, you give it a name, then you can commit or roll back that transaction, by name. This means if you're doing transactions recursively, you should use a counter in your name. Nymph uses internal transactions that start with the "nymph-" prefix, so don't use that. You should be sure to commit or rollback transactions in the reverse order that they were started, or you might run into trouble.

Manually Creating Transactions

Nymph has the following instance methods for manually managing transactions.

  • startTransaction - Start a named transaction.
  • commit - Commit a named transaction.
  • rollback - Rollback a named transaction.
  • inTransaction - Check if Nymph is currently running within a transaction.

startTransaction will return a new, transactional instance of Nymph. For the rest of the transaction, until it is committed or rolled back, you should use this instance. It will be tied to a specific connection to the database.

The transactional Decorator

Nymph also exports a decorator, transactional, that can be used on an async entity instance method to run the entire method in a transaction. If the method resolves, the transaction will be committed, and if the method throws, the transaction will be rolled back. If needed, you can then run $refresh on the entity to undo any changes to its data that didn't end up in the database. This decorator doesn't require that you name a transaction, because it will use the method's name, appended with a long random string.

The transaction Helper Function

Nymph also exports a transaction helper function that accepts another async function to run within a transaction. The passed in function is given a transactional instance of Nymph. If the function resolves, the transaction is committed, and if the function throws, the transaction is rolled back. Regardless, a finally function is allowed to run before promise resolution/rejection and is passed the original instance of Nymph. This is helpful if you need a transaction around only part of a method, or something that isn't an entity instance method.

Handling Transactional Instances

The transactional instance of Nymph has its own set of classes. You can use its getEntityClass method to get the proper classes for that instance of Nymph. This is only stictly necessary for factory and other static methods, since Nymph's getEntities and getEntity methods will always use the correct classes to instantiate entities, even if you pass in one from a higher instance.

When you start a new transaction, entity classes and instances retrieved from that transactional Nymph instance will have that instance within their static nymph property and instance $nymph property. As long as the code in your entities always uses these instances, every query and change should occur within that transaction.

Since it is very important to use the right instance when you are using transactions, you should use the $setNymph(nymph) method on your entities within transactions. When you are done with the transaction, you can call the $setNymph method again, with the original Nymph instance. The transactional decorator will do this for you, but you need to do it yourself if you are using the manual transaction functions or the transaction helper function.

Transaction Support

Not all databases that Nymph supports (specifically, MySQL with an engine that doesn't support transactions, like the MyISAM engine) can use transactions. To ensure data consistency, it's highly recommended to use a configuration that supports transactions.

Examples

Here is an example of a class that uses a transaction to delete all of its children when it is deleted. If any of its children cannot be deleted, then the transaction is rolled back, meaning none of its children get deleted. There is an example for each method of creating this transaction.

import {
  EntityUniqueConstraintError,
  type Nymph,
  transactional,
  transaction,
} from "@nymphjs/nymph";
import { Entity, nymphJoiProps } from '@nymphjs/nymph';
import type { AccessControlData } from '@nymphjs/tilmeld';
import { enforceTilmeld, tilmeldJoiProps } from '@nymphjs/tilmeld';
import Joi from 'joi';

export interface TodoData extends AccessControlData {
  name: string;
  done: boolean;
  parent?: Todo;
}

export interface Todo extends TodoData {}

export class Todo extends Entity<TodoData> {
  static ETYPE = 'todo';
  static class = 'Todo';

  protected $clientEnabledMethods = [];
  protected $allowlistData? = ['name', 'done', 'parent'];
  protected $protectedTags = [];
  protected $allowlistTags? = [];

  constructor() {
    super();

    this.name = '';
    this.done = false;
  }

  async $getUniques() {
    return [
      `${this.user?.guid}:${this.parent?.guid}:${this.name}`,
    ];
  }

  async $save() {
    const tilmeld = enforceTilmeld(this);
    if (!tilmeld.gatekeeper()) {
      // Only allow logged in users to save.
      throw new Error("You are not logged in.");
    }

    // Validate the entity's data.
    Joi.attempt(
      this.$getValidatable(),
      Joi.object().keys({
        ...nymphJoiProps,
        ...tilmeldJoiProps,

        name: Joi.string().trim(false).max(500, "utf8").required(),
        done: Joi.boolean().required(),
        parent: Joi.object().instance(Todo),
      }),
      "Invalid Todo: ",
    );

    try {
      await super.$save();
    } catch (e: any) {
      if (e instanceof EntityUniqueConstraintError) {
        throw new Error("There is already a todo for that.");
      }
      throw e;
    }
  }

  // Using the transactional decorator.
  @transactional
  async $delete() {
    // Delete this todo's children.
    const children = await this.$nymph.getEntities(
      { class: Todo, skipAc: true },
      { type: "&", ref: ["parent", this] },
    );

    for (let child of children) {
      await child.$delete();
    }

    // Delete todo.
    await super.$delete();
  }

  // Using the transaction helper function.
  async $delete() {
    return await transaction(
      this.$nymph,
      "todo-delete-" + this.guid,
      async (nymph: Nymph) => {
        // Set the instance nymph to the transactional nymph.
        this.$setNymph(nymph);

        // Delete this todo's children.
        const children = await this.$nymph.getEntities(
          { class: Todo, skipAc: true },
          { type: "&", ref: ["parent", this] },
        );

        for (let child of children) {
          await child.$delete();
        }

        // Delete todo.
        await super.$delete();
      },
      async (nymph: Nymph) => {
        // Set the instance nymph back to the original nymph.
        this.$setNymph(nymph);
      },
    );
  }

  // Manually handling a transaction.
  async $delete() {
    const transaction = "todo-delete-" + this.guid;
    const nymph = this.$nymph;
    const tnymph = await nymph.startTransaction(transaction);
    this.$setNymph(tnymph);

    try {
      // Delete this todo's children.
      const children = await tnymph.getEntities(
        { class: Todo, skipAc: true },
        { type: "&", ref: ["parent", this] },
      );

      for (let child of children) {
        await child.$delete();
      }

      // Delete todo.
      await super.$delete();

      // Commit transaction.
      await tnymph.commit(transaction);

      this.$setNymph(nymph);
    } catch (e: any) {
      await tnymph.rollback(transaction);
      this.$setNymph(nymph);
      throw e;
    }
  }
}

The $setNymph method is used to make sure the entity and all referenced entities use the transactional Nymph instance. The tnymph Nymph instance is used during the transaction. You don't need to worry about which instance you pass to getEntities, because it will always retrieve and use the correct instance. Factory methods will not, however.

Previous: UIDs Next: Full Text Search