1.0.2 • Published 1 year ago

context-container v1.0.2

Weekly downloads
-
License
MIT
Repository
-
Last release
1 year ago

A server-side context container for TypeScript. Allows a 'Context' object to be shared throughout the lifecycle of a 'job', such as a web request, script execution, or task handler.

You create the context container (CC) at the beginning of each job, and then pass it as an argument to all your functions and classes. They can then use it to access shared code, state, cached results, and the time that the job started.

There are currently four features:

  1. Plugins
  2. Singletons
  3. Memoization
  4. Request timestamp

Installation

yarn add context-container

or

npm install context-container

Features

Plugins

Plugins allow you to write your high-level application code in a way that specifies interfaces for lower-level functionality that needs to exist for the application code to run, without having the high-level code depend on the low-level code. This is to support the ideas of Clean Architecture.

To use a plugin, first, create the definition of the plugin type and an object that will be used to reference it. The purpose of the "reference" object is to have something that high-level code can point to without knowing about the implementation of the plugin.

import { createPluginReference } from 'context-container';

export type SerializeObjectPluginType = {
  serialize: (object: unknown) => string;
};

export const SerializeObjectPlugin = createPluginReference<SerializeObjectPluginType>();

Then write your code that needs to use the plugin without caring how it's implemented:

import { CC } from 'context-container';
import { SerializeObjectPlugin } from 'your/code/SerializeObjectPlugin';

function doSomething(cc: CC, data: unknown) {
  const serializedData = cc.getPlugin(SerializeObjectPlugin).serialize(data);
  ...
}

Then write your implementation of the plugin:

import { SerializeObjectPluginType } from 'your/code/SerializeObjectPlugin';

export const SerializeObjectPluginImpl: SerializeObjectPluginType = {
  serialize: (object: unknown) => JSON.stringify(object),
};

Finally, link the plugin reference and implementation when the context container is created. This should happen at the beginning of handling your job (web request, script, task executor, etc.)

import { ContextContainerFactory } from 'context-container';
import { SerializeObjectPlugin } from 'your/code/SerializeObjectPlugin';
import { SerializeObjectPluginImpl } from 'your/code/SerializeObjectPluginImpl';

function main() {
  const cc = ContextContainerFactory.create([
    {
      reference: SerializeObjectPlugin,
      implementation: SerializeObjectPluginImpl
    },
  ]);
  executeProgram(cc, args);
}

Singletons

A Contextual Singleton is a class that has one instance per Context Container. The usage pattern for Context Containers is to create a new one at the beginning of handling each job (e.g., web request, script, task executor, etc.) This means that for ContextualSingletons, you will have one instance per job run, and different jobs will not share instances of this the class.

Define the singleton class:

class AuthenticatedViewer extends ContextualSingleton {
  private viewer: Viewer | undefined;
  public set(viewer: Viewer) { this.viewer = viewer; }
  public get(): Viewer {
    const viewer = this.viewer;
    if (viewer == null) {
      throw new Error('Viewer not set');
    }
    return viewer;
  }
}

In this example, we will initialize the data at the beginning of the job and then access it several times throughout the job. At the beginning of the job:

const cc = ContextContainerFactory.create([...plugins]);
...
const viewer = authenticateUser(...);
cc.getSingleton(AuthenticatedViewer).set(viewer);
executeProgram(cc, args);

Later on in the job:

function doSomething(cc: ContextContainer) {
  const viewer = cc.getSingleton(AuthenticatedViewer).get();
  ...
}

Memoization

Memoization here is done in the context of a particular job (web request, script, task executor, etc.). That means memoized values are not shared between different jobs, and are discarded at the end of the job. If your jobs are short-lived, this is usually a reasonable way of avoiding having stale data in your memoization cache.

This also means that if your job is done with the same user permissions for the entire job, then you can memoize results that are subject to user permission checks. E.g., if user A is logged in, and they attempt to access resource X, then you can memoize the value that is returned to them, which includes both the resource X and the fact that they are allowed to access it.

However, if you do this, be careful about any resources that may change while the job is running, particularly if the job may change the resource or the permissions associated with it.

As a more cautious alternative, you can memoize the fetched resource (perhaps as a permission-less database proxy object), and then check the permissions on the resource every time you access it.

Defining a memoized function:

class UserEmailPreferences {
 public static async load(
   cc: CC,
   userID: string,
 ): Promise<UserEmailPreferences> {
   const dbProxy = await cc.memoize(
     this,
     userID,
     async () => await cc
       .getPlugin(DatabasePlugin)
       .loadUserEmailPreferences(userID),
   );
   if (!(await UserEmailPreferencesPrivacyImpl.canRead(cc, userID, dbProxy))) {
     throw new PermissionError();
   }
   return new UserEmailPreferences(cc, userID, dbProxy);
 }
}

Using the memoized function:

async function doSomething(cc: CC, userID: string) {
  const userEmailPreferences = await UserEmailPreferences.load(cc, userID);
  ...
}

Timestamp

Timestamp is included here because it is common to use a single timestamp as the canonical time that things occurred in a job (web request, script, task executor, etc.).

For example, if you are processing a web request, you may want to use the timestamp that the request started as the updatedTime when updating an object in persistent storage.

If updating multiple objects, you may want to use the same timestamp for all of them, rather than the exact timestamp that the code happened to be running at when handling that particular object.

If you want to customize the timestamp, rather than using the time that the ContextContainer was created, you can pass a timestamp as an argument to ContextContainerFactory.create().

Timestamp could have been implemented as a Singleton. In fact, that's how it was originally implemented. But because it's so common and so useful, it's worth having a dedicated API for it. If you're considering attaching similar data to the ContextContainer, consider using a Singleton instead. See the Singleton section above for how to do this.

1.0.2

1 year ago

1.0.1

1 year ago

1.0.0

1 year ago