npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

graphql-node-version

v1.1.0

Published

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.

Downloads

22

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
            }
        }
    }
}