1.1.0 • Published 4 years ago

graphql-node-version v1.1.0

Weekly downloads
6
License
Apache-2.0
Repository
github
Last release
4 years ago

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:

fielddescriptiontype
logOptionsAny logger options. Useful if you want to set the logger to debug modepino.LoggerOptions
loggerThe pino logger to use instead of making a new oneReturnType<typeof pino>
namesThe 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:

fielddescriptiontype
userIdThe id of the user who made the GQL request(parent, args, ctx, info) => string | number
userRolesThe permission roles of the user who made the GQL request(parent, args, ctx, info) => string[]
revisionDataThe data that should be stored as the diff for this resolver operation(parent, args, ctx, info) => any
eventTimeOPTIONAL - The UTC ISO time of the recording. If not supplied, it will default to the current UTC ISO time(parent, args, ctx, info) => string
knexThe knex client used for storing revision information(parent, args, ctx, info) => Knex
nodeIdThe id of the node who is being versioned(node, parent, args, ctx, info) => Knex
nodeSchemaVersionThe schema version of the node who is being versionednumber | string
nodeNameThe name of the node who is being versionedstring
resolverOperationOPTIONAL - 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 resolverstring
currentNodeSnapshotA 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>
currentNodeSnapshotFrequencyOPTIONAL - 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
parentNodeOPTIONAL - 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}
edgesOPTIONAL - 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:

fielddescriptiontype
nodeIdThe id of the nodestring | number
nodeNameThe name of the nodestring
nodeBuilderA function that applies node diffs (from the versionInfo or fragmentNodes) to the previous node snapshot in order to calculate the new nodesee below for type
fragmentNodeBuilderA 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
            }
        }
    }
}