0.2.0 • Published 2 years ago

@sabl/txn v0.2.0

Weekly downloads
-
License
MIT
Repository
github
Last release
2 years ago

codecov

version: 0.2.0 | tag: v0.2.0 | commit: f8a1ce5fe

@sabl/txn

txn is a simple, context-aware pattern for describing transactions - batches of operations which should all succeed together or be rolled back. The pattern can be used to run actual storage system transactions, but it is also useful for running conceptual transactions purely in a client runtime, which avoid the blocking costs of native database transactions but still allow clean up of resources if a series of operations does not all succeed.

Defining these interfaces and algorithms in the abstract allows authors to write effective business logic that includes transaction workflows, without depending on a specific storage type, let alone a specific proprietary driver. This is in turn allows concise and testable code while avoiding over-dependence on implementation details of underlying storage choices.

For more detail on the txn pattern, see sabl / patterns / txn.

Concepts

This library contains interfaces, context getters and setters, and several generic algorithms for running transactions. The APIs support situations where the underlying transaction type is known to the code initiating the transaction (say, a MySQLTxn), as well as patterns that allow transactions to be run even without the code initiating the transaction knowing the underlying type.

The ChangeSet type included in the library is an entirely client-side transaction, which simply accumulates a set of callbacks to execute on commit and/or on rollback. It can be combined with an underlying storage transaction with TxnChangeSet, illustrated below.

Txn interface

A simple representation of a transaction which can be either committed or rolled back.

interface Txn {
  commit(): Promise<void>;
  rollback(): Promise<void>;
}

Transactable interface

A transactable is any object that can start a transaction. Often this is a database pool or connection. If an underlying service supports nested transactions, the transactable could itself be a transaction.

export interface Transactable<T extends Txn> {
  beginTxn(ctx: IContext, opts?: TxnOptions): Promise<T>;
}

Transaction options mostly apply to common relational database patterns, and can always be omitted:

interface TxnOptions {
  readonly isolationLevel?: IsolationLevel;
  readonly readOnly?: boolean;
}
 
enum IsolationLevel {
  default = 1,
  readUncommitted = 2,
  readCommitted = 3,
  writeCommitted = 4,
  repeatableRead = 5,
  snapshot = 6,
  serializable = 7,
  linearizable = 8,
}

ChangeSet

A ChangeSet is an in-memory transaction which simply accumulates a list of callbacks to invoke either on commit or rollback.

interface ChangeSet extends Txn {
  defer(fn: (ctx: IContext) => Promise<void>): void;
  deferFail(fn: (ctx: IContext) => Promise<void>): void;
  commit(): Promise<void>;
  rollback(): Promise<void>;
}

All callbacks registered with defer are executed in order when commit is called. If any of them fail, or if rollback is called explicitly, then all the callbacks registered with deferFail are executed in order.

TxnChangeSet

A TxnChangeSet combines both the in-memory ChangeSet and an underlying transaction, usually in a database.

 interface TxnChangeSet<T extends Txn> extends ChangeSet {
  deferTxn(fn: (ctx: IContext, txn: T) => Promise<void>): void;
  defer(fn: (ctx: IContext) => Promise<void>): void;
  deferFail(fn: (ctx: IContext) => Promise<void>): void;
  commit(): Promise<void>;
  rollback(): Promise<void>;
}

Callbacks registered with deferTxn will be run in a single underlying transaction. Callbacks registered with the base defer will be run only after the transaction, if needed, has successfully committed. Callbacks registered with deferFail are run if there are any errors in either the transaction or non-transaction callbacks, if committing the underlying transaction fails, or if rollback is called explicitly.

Context

The Txn and Transactable types are abstract. To be useful as wrappers for actual storage transactions, authors must implement a handful of wrappers:

  • Implementations of Txn which wrap some platform-specific transaction API
  • Implementations of Transactable which wrap some platform-specific connection and/or pool API
  • A context getter and setter for their Transactable type
  • A context getter and setter for their Txn type

Example - MySQL

As an example, here is a summarized implementation for MySQL which wraps the mysql2/promise APIs:

import { IContext } from '@sabl/txn';
import { Txn, TxnOptions, Transactable } from '@sabl/txn';
import { Connection } from 'mysql2/promise';

interface MySQLApi {
  execute(...);
  query(...);
}

class MySQLTxn implements Txn, MySQLApi {
  constructor(readonly con: Connection) {}
  begin(ctx: IContext, opts?: TxnOptions) {
    return this.con.execute('START TRANSACTION');
  }
  async commit(): Promise<void> {
    await this.con.execute('COMMIT');
  }
  async rollback(): Promise<void>{
    await this.con.execute('ROLLBACK');
  }
  execute(...) { return this.con.execute(...) }
  query(...) { return this.con.query(...) } 
}

class MySQLCon implements Transactable<MySQLTxn>, MySQLApi {
  constructor(readonly con: Connection) {}
  async beginTxn(ctx: IContext, opts?: TxnOptions): Promise<MySQLTxn> {
    const txn = new MySQLTxn(this.con);
    await txn.begin(ctx, otps);
    return txn;
  }
  execute(...) { return this.con.execute(...) }
  query(...) { return this.con.query(...) } 
}

All we need to use this with the transaction running API in this library are a few context getters and setters:

import { IContext, Context, Maybe, withValue } from '@sabl/txn';

const ctxKeyMySQLCon = Symbol('MySQLCon');
const ctxKeyMySQLTxn = Symbol('MySQLTxn');

function withMySQLCon(ctx: IContext, con: MySQLCon): Context {
  return withValue(ctx, ctxKeyMySQLCon, con);
}
function getMySQLCon(ctx: IContext): Maybe<MySQLCon> {
  return <Maybe<MySQLCon>>ctx.value(ctxKeyMySQLCon);
}
function withMySQLTxn(ctx: IContext, con: MySQLTxn): Context {
  return withValue(ctx, ctxKeyMySQLTxn, con);
}
function getMySQLTxn(ctx: IContext): Maybe<MySQLTxn> {
  return <Maybe<MySQLTxn>>ctx.value(ctxKeyMySQLTxn);
}

// Get either Con or Txn to run queries
function getMySQLApi(ctx: IContext): Maybe<MySQLApi> {
  return getMySQLTxn(ctx) || getMySQLCon(ctx);
}

We can now run transactions where we know we're working with MySQL:

import { txn } from '@sabl/txn';

const con = getMySQLConnection();
const ctx = Context.value(withMySQLCon, con);

await txn({
  getTransactable: getMySQLCon,
  getTxn: getMySQLTxn,
  withTxn: withMySQLTxn
}).run(ctx, async (ctx, txn) => {
  await txn.execute('insert x into y')
  await txn.execute('delete from w from z = 1')
})

Alternatively, we can register the transaction accessors on the context itself and allow downstream code to run transactions without knowing or caring about what kind of database it is:

server.ts

import { withTxnAccessor } from '@sabl/txn';

const con = getMySQLConnection();
const ctx = Context.background.
  withValue(withMySQLCon, con).
  withValue(withTxnAccessor, {
    getTransactable: getMySQLCon,
    getTxn: getMySQLTxn,
    withTxn: withMySQLTxn
  })
;

// ... inject ctx into all requests ...

ecommerce-service.ts

import { txn } from '@sabl/txn';

export async function buySomething(ctx: IContext, ...) {
  const [ repo, taxSvc ] = Context.as(ctx).require(
    getRepo,
    getTaxSvc
  );

  // Uses getMySQLCon, getMySQLTxn, withMySQLTxn registered upstream
  await txn(ctx).run(ctx, async (ctx) => {
    const invoice = await repo.createInvoice(ctx, ...);
    const invoiceLine = await repo.createInvoiceLine(ctx, invoice, ...);
    const taxItem = await taxSvc.addTaxes(ctx, invoice);
  })
}