graphql-node-version

Handle versioning of GraphQL nodes. Easily capture node changes caused from mutations by wrapping resolvers in decorators. Use Relay-like connections to query versions of a node through time.

Usage no npm install needed!

<script type="module">
  import graphqlNodeVersion from 'https://cdn.skypack.dev/graphql-node-version';
</script>

README

graphql-node-version 🌗 🌑 🌓

Handle versioning of GraphQL nodes. Easily capture node changes caused from mutations by wrapping resolvers in decorators. Use Relay-like connections to query versions of a node through time.

For example, with this library, you can create GraphQL nodes that allow you to query like this:

query {
    campaign(
        campaignId: 8021
        first: 50
        filter: {
            and: [
                {field: "id", operator: "=", value: "2"}
                {field: "userId", operator: "=", value: "105208"}
                {field: "userRole", operator: "=", value: "users"}
                {field: "nodeId", operator: "=", value: "145"}
                {field: "nodeName", operator: "=", value: "CAMPAIGN"}
                {field: "createdAt", operator: "=", value: "1572048220"}
                {field: "type", operator: "=", value: "LINK_CHANGE"}
                {field: "resolverOperation", operator: "=", value: "CREATE_CAMPAIGN"}
            ]
        }
    ) {
        pageInfo {
            endCursor
            startCursor
            hasPreviousPage
            hasNextPage
        }
        edges {
            cursor
            version {
                id
                userId
                userRoles
                nodeId
                nodeName
                createdAt
                type
                resolverOperation

                ... on VersionNodeChange {
                    revisionData
                    nodeSchemaVersion
                }

                ... on VersionNodeLinkChange {
                    linkNodeId
                    linkNodeName
                }

                ... on VersionNodeFragmentChange {
                    childNodeId
                    childNodeName
                    childRevisionData
                    childNodeSchemaVersion
                }
            }
            node {
                ...InfoSpecificToEachNode
            }
        }
    }
}

Important Notes:

  • Knex is locked at 0.20.13. Knex doesn't follow semver correctly. To avoid conflicts between packages and services we will use this version going forward. If you change this version, you may have to update every package.

Install

1. Download

npm install --save @social-native/graphql-node-version

2. Migrations

This package installs knex migrations into the dependent service. A binary is published that you can call to add the migrations. For example, you can add this to your npm scripts:

    scripts: {
        "add-version-migrations": "ts-node --project tsconfig.json node_modules/.bin/graphql-node-version --knexfile knexfile.js",
        ...
        "postinstall": "npm run add-version-migrations"
    },

Note: In order for this to work, you need to have a knexfile.js in the root of the repo.

How to version a node

1. Set the configuration

In the src folder create a src/version.ts file. This file is used to keep track of NODE_NAME and RESOLVER_OPERATION enums and the instantiatied versionRecorder and versionConnection functions.

NODE_NAMES

Versions are recorded for each node instance. A node instance contains an id and a name. The names of all nodes should be stored in an enum called NODE_NAME.

For example:

export enum NODE_NAME {
    DIRECTION_TREE = 'DIRECTION_TREE',
    PRODUCTION_TREE_NODE = 'PRODUCTION_TREE_NODE'
}

RESOLVER_OPERATION

Resolvers operate on node instances. Common operations are CREATE, UPDATE, DELETE, but there might be more specific ones if your node represents a tree. List all the node operations in an enum called RESOLVER_OPERATION.

For example:

export enum DIRECTION_TREE_RESOLVER_OPERATION {
    CREATE_FULL_TREE = 'CREATE_FULL_TREE',
    UPDATE_RULE = 'UPDATE_RULE',
    UPDATE_BRANCH = 'UPDATE_BRANCH',
    UPDATE_CONNECTIVE = 'UPDATE_CONNECTIVE',
    DELETE_FULL_TREE = 'DELETE_FULL_TREE',
    DELETE_BRANCH = 'DELETE_BRANCH'
}

versionRecorder and versionConnection instances

You will use these instances in decorators or directly in resolvers to version a node.

This package uses the Pino logger. A common setup is to pass the instantiated Pino logger to the class constructor, for example:

import logger from 'logger';

export const versionRecorder = VersionRecorder({
    logger
    // logOptions: {
    //     level: 'info' // enable and remove `logger` to create a new logger with this log level
    // }
});

export const versionConnection = VersionConnection({
    logger
    // logOptions: {
    //     level: 'info' // enable and remove `logger` to create a new logger with this log level
    // }
});

If you wanted more specific logging you could enable debug logging, in which case the class would generate a pino logger instance internally:

import {
    versionRecorderDecorator as VersionRecorder,
    versionConnection as VersionConnection
} from 'graphql-node-version';

export const versionRecorder = VersionRecorder({
    logOptions: {
        level: 'debug'
    }
});

export const versionConnection = VersionConnection({
    logOptions: {
        level: 'debug'
    }
});

Common versionRecorder configuration

versionRecorder requires information in order to successfully map inputs and output to version information. For snapi services, the common configuration describes how to:

  • get access to the kenx client
  • extract the userId
  • extract the userRoles
export const commonVersionRecorderDecoratorConfig = <T extends Resolver<any, any, any>>() =>
    ({
        knex: (_, __, {clients}) => clients.sqlClient.connection,
        userId: (_, __, {user}) => {
            if (user) {
                if (!user.app_user_id) {
                    throw new Error('Missing user id');
                }
                return user.app_user_id.toString();
            } else {
                throw Error('Missing user');
            }
        },
        userRoles: (_, __, {user}) => {
            if (user) {
                return user.roles;
            } else {
                throw Error('Missing user');
            }
        }
    } as Pick<IVersionRecorderExtractors<T>, 'knex' | 'userId' | 'userRoles'>);

2. Version recording

Capturing version information works by decorating mutation resolvers and intercepting the resolvers inputs and result.

You will need to provide mapping functions or fields for each node. At a minimum, you need to provide:

  • revisionData
  • nodeName
  • currentNodeSnapshotFrequency
  • currentNodeSnapshot
  • nodeSchemaVersion
  • nodeId
  • resolverOperation

1. Import the versionRecord instance

# src/resolvers/mutation/index.ts

import {
    NODE_NAME,
    versionRecorder,
    RESOLVER_OPERATION,
    commonVersionRecorderDecoratorConfig
} from 'version';

2. Define common configuration for each node type:

const productionTreeConfig = <T extends Resolver<any, any, any>>() =>
    ({
        revisionData: (_, args) => args,
        nodeName: NODE_NAME.PRODUCTION_TREE,
        currentNodeSnapshotFrequency: 1,  <----- how often a full node snapshot should be stored
        currentNodeSnapshot: async (nodeId, args) => {   <------ a function to get a full node snapshot
            const conn = await query.productionTree(
                undefined,
                {productionId: nodeId as string},
                args[2],
                args[3]
            );
            return conn.edges[0].node;  <---- note that this is extracting the node from a version connection
        },
        nodeSchemaVersion: 1  <----- the schema version of this node
    } as Pick<
        IVersionRecorderExtractors<T>,
        | 'revisionData'
        | 'nodeName'
        | 'currentNodeSnapshotFrequency'
        | 'currentNodeSnapshot'
        | 'nodeSchemaVersion'
    >);

3. For each mutation resolver, decorate it

For example:

decorate(mutation, {
    productionTreeCreate: versionRecorder<ProductionTreeCreate>({
        ...commonVersionRecorderDecoratorConfig<ProductionTreeCreate>(),
        ...productionTreeConfig<ProductionTreeCreate>(),
        resolverOperation: RESOLVER_OPERATION.CREATE,
        nodeId: node => node.createdNodeId,
        edges: (_node, _parent, {productionId}) => [
            {nodeId: productionId, nodeName: NODE_NAME.PRODUCTION}
        ]
    }),
    productionTreeBranchCreate: versionRecorder<ProductionTreeBranchCreate>({
        ...commonVersionRecorderDecoratorConfig<ProductionTreeBranchCreate>(),
        ...productionTreeConfig<ProductionTreeBranchCreate>(),
        resolverOperation: RESOLVER_OPERATION.CREATE_BRANCH,
        nodeId: node => node.updatedNodeId
    }),
    productionTreeNodeUpdate: versionRecorder<ProductionTreeNodeUpdate>({
        ...commonVersionRecorderDecoratorConfig<ProductionTreeNodeUpdate>(),
        ...productionTreeConfig<ProductionTreeNodeUpdate>(),
        resolverOperation: RESOLVER_OPERATION.UPDATE_NODE,
        nodeId: node => node.updatedNodeId
    }),
    productionTreeBranchUpdate: versionRecorder<ProductionTreeBranchUpdate>({
        ...commonVersionRecorderDecoratorConfig<ProductionTreeBranchUpdate>(),
        ...productionTreeConfig<ProductionTreeBranchUpdate>(),
        resolverOperation: RESOLVER_OPERATION.UPDATE_BRANCH,
        nodeId: node => node.updatedNodeId
    }),
    productionTreeBranchDelete: versionRecorder<ProductionTreeBranchUpdate>({
        ...commonVersionRecorderDecoratorConfig<ProductionTreeBranchUpdate>(),
        ...productionTreeConfig<ProductionTreeBranchUpdate>(),
        resolverOperation: RESOLVER_OPERATION.DELETE_BRANCH,
        nodeId: node => node.updatedNodeId
    }),
    productionTreeDelete: versionRecorder<ProductionTreeBranchUpdate>({
        ...commonVersionRecorderDecoratorConfig<ProductionTreeBranchUpdate>(),
        ...productionTreeConfig<ProductionTreeBranchUpdate>(),
        resolverOperation: RESOLVER_OPERATION.DELETE,
        nodeId: node => node.updatedNodeId
    })

3. Version querying

Versions queries return a versionConnection

This has the type:

export interface IVersionConnection<Node> {
    edges: Array<{
        cursor: string;
        version?: IGqlVersionNode;
        node?: Node;
    }>;
    pageInfo: {
        hasNextPage: boolean;
        hasPreviousPage: boolean;
        startCursor: string;
        endCursor: string;
    };
}

In order to create a versionConnection from a regular node, you simply pass in the resolver node result into the versionConnection instance.

For example:

const directionTree: DirectionTreeQuery = async (parent, args, ctx, info) => {
    const {connection} = ctx.clients.sqlClient;
    const records = (await directionQueryBuilder((connection as Knex).queryBuilder())
        .orWhere({'direction.root_id': args.directionTreeId})
        .orWhere({'direction.id': args.directionTreeId})) as IDirectionSQL[];

    const currentNode = records.length > 0 ? buildDirectionsNode(records) : null;

    return await versionConnection<DirectionTreeQuery, DirectionTreeNodeRevisionData>(
        currentNode,
        [parent, args, ctx, info],
        {
            knex: ctx.clients.sqlClient.connection,
            nodeBuilder: node => node,
            nodeId: args.directionTreeId,
            nodeName: NODE_NAME.DIRECTION_TREE
        }
    );
};
export default directionTree;

API

1. Builders

versionRecorder and versionConnection are both imported from the lib directly:

import {
    versionRecorderDecorator as versionRecorderBuilder,
    versionConnection as versionConnectionBuilder
} from 'graphql-node-version';

Both of these functions are actually builder functions that takes a config object with the type:

export interface IConfig extends ILoggerConfig {
    logOptions?: pino.LoggerOptions;
    logger?: ReturnType<typeof pino>;
    names?: ITableAndColumnNames;
}

Config Object:

field description type
logOptions Any logger options. Useful if you want to set the logger to debug mode pino.LoggerOptions
logger The pino logger to use instead of making a new one ReturnType<typeof pino>
names The table and column names used in sql. If you set custom names in the migration, you should also supply them here. ITableAndColumnNames
export interface ISqlColumnNames {
    event: StringValueWithKey<ISqlEventTable>;
    event_implementor_type: StringValueWithKey<ISqlEventImplementorTypeTable>;
    event_link_change: StringValueWithKey<ISqlEventLinkChangeTable>;
    event_node_change: StringValueWithKey<ISqlEventNodeChangeTable>;
    event_node_fragment_register: StringValueWithKey<ISqlEventNodeFragmentChangeTable>;
    role: StringValueWithKey<ISqlRoleTable>;
    user_role: StringValueWithKey<ISqlUserRoleTable>;
    node_snapshot: StringValueWithKey<ISqlNodeSnapshotTable>;
}
export interface ITableAndColumnNames extends ISqlColumnNames {
    table_names: StringValueWithKey<ISqlColumnNames>;
}

2. VersionRecorder

When you use the versionRecorder you need to supply extractors to map the resolver inputs and outputs to the versionRecorder:

field description type
userId The id of the user who made the GQL request (parent, args, ctx, info) => string | number
userRoles The permission roles of the user who made the GQL request (parent, args, ctx, info) => string[]
revisionData The data that should be stored as the diff for this resolver operation (parent, args, ctx, info) => any
eventTime OPTIONAL - The UTC ISO time of the recording. If not supplied, it will default to the current UTC ISO time (parent, args, ctx, info) => string
knex The knex client used for storing revision information (parent, args, ctx, info) => Knex
nodeId The id of the node who is being versioned (node, parent, args, ctx, info) => Knex
nodeSchemaVersion The schema version of the node who is being versioned number | string
nodeName The name of the node who is being versioned string
resolverOperation OPTIONAL - The name of the resolver operating on the node who is being versioned. If not supplied the decorator will use the property name of the decorated resolver string
currentNodeSnapshot A function to call that will return the current node. This is called after the mutation has been persisted to the database. This should likely be a query resolver. (node, parent, args, ctx, info) => Promise<Node>
currentNodeSnapshotFrequency OPTIONAL - The frequency at which full node snapshots will be taken. If not supplied, it will default to 1 which means every time there is a recording a snapshot will be taken. number
parentNode OPTIONAL - If this node is a fragment or child of a node (it doesnt have a true independent representation in the graph but has resolvers that act on it directly), this function provides a mapping to the parentNode's identifying info. (node, parent, args, ctx, info) => {nodeName: string, nodeId: string | number}
edges OPTIONAL - Edges to other nodes that are created by the resolver. (node, parent, args, ctx, info) => Array<{nodeName: string, nodeId: string | number}>

3. VersionConnection

When you use the versionConnection you need to supply extractors to tell the versionConnection how to construct historical versions from recorded diffs and intermittent snapshots:

field description type
nodeId The id of the node string | number
nodeName The name of the node string
nodeBuilder A function that applies node diffs (from the versionInfo or fragmentNodes) to the previous node snapshot in order to calculate the new node see below for type
fragmentNodeBuilder A function that applies node diffs (from the versionInfo) to the previous fragment node snapshot in order to calculate the new fragment node (childNode) see below for type
const nodeBuilder<Node> =
   (previousNode: Node,
    versionInfo: IAllNodeBuilderVersionInfo,
    fragmentNodes?: INodeBuilderFragmentNodes,
    logger?: ILoggerConfig['logger']
  ) => Node;
    `
const fragmentNodeBuilder<ChildNode> =
   (previousNode: ChildNode,
    versionInfo: IAllNodeBuilderVersionInfo,
    logger?: ILoggerConfig['logger']
  ) => Node;
    `

GQL Example Usage

Versioned nodes are represented as connections. If you are unfamilar with the Relay connection spec you can read about it here. This library extends the connection type by adding a version field to the edges field. The version field has three unique implementors VersionNodeChange, VersionNodeLinkChange, and VersionNodeFragmentChange. For the most part, unless you are doing something special you will just use VersionNodeChange and VersionNodeLinkChange to get version information about node and node links (aka edges to other nodes) changes.

Each edge in a versioned connection represents a version of the node. By default, the nodes are sorted youngest to oldest. Thus, calling a version connection for the first node will give you the current node

You can also use graphql-connection filters in a version connection query.

The fields available to filter on are:

  • id
  • userId
  • userRole
  • nodeId
  • nodeName
  • createdAt
  • type
  • resolverOperation

An example query with extensive filtering could look like:

query {
    directionTree(
        directionTreeId: 8021
        first: 50
        filter: {
            and: [
                {field: "id", operator: "=", value: "2"}
                {field: "userId", operator: "=", value: "105208"}
                {field: "userRole", operator: "=", value: "users"}
                {field: "nodeId", operator: "=", value: "145"}
                {field: "nodeName", operator: "=", value: "DIRECTION_TREE"}
                {field: "createdAt", operator: "=", value: "1572048220"}
                {field: "type", operator: "=", value: "LINK_CHANGE"}
                {field: "resolverOperation", operator: "=", value: "CREATE_FULL_TREE"}
            ]
        }
    ) {
        pageInfo {
            endCursor
            startCursor
            hasPreviousPage
            hasNextPage
        }
        edges {
            cursor
            version {
                id
                userId
                userRoles
                nodeId
                nodeName
                createdAt
                type
                resolverOperation

                ... on VersionNodeChange {
                    revisionData
                    nodeSchemaVersion
                }

                ... on VersionNodeLinkChange {
                    linkNodeId
                    linkNodeName
                }

                ... on VersionNodeFragmentChange {
                    childNodeId
                    childNodeName
                    childRevisionData
                    childNodeSchemaVersion
                }
            }
            node {
                ...InfoSpecificToEachNode
            }
        }
    }
}