Skip to content

Add plugin system #133

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
mailaneel opened this issue May 14, 2024 · 3 comments
Open

Add plugin system #133

mailaneel opened this issue May 14, 2024 · 3 comments
Labels

Comments

@mailaneel
Copy link

mailaneel commented May 14, 2024

Add hook/plugins functionality so we can tap into these methods set, upset, update, delete etc.,

This allows basic functionality like

  • validations
  • timestamps
@mailaneel
Copy link
Author

mailaneel commented May 14, 2024

Current setup for us includes proxy, but does not allow full feature set

// NOTE: lifted from trpc codebase

interface ProxyCallbackOptions {
  path: string[];
  args: unknown[];
}
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;

const noop = () => {
  // noop
};

function createInnerProxy(callback: ProxyCallback, path: string[]) {
  const proxy: unknown = new Proxy(noop, {
    get(_obj, key) {
      if (typeof key !== 'string' || key === 'then') {
        // special case for if the proxy is accidentally treated
        // like a PromiseLike (like in `Promise.resolve(proxy)`)
        return undefined;
      }
      return createInnerProxy(callback, [...path, key]);
    },
    apply(_1, _2, args) {
      const isApply = path[path.length - 1] === 'apply';
      return callback({
        args: isApply ? (args.length >= 2 ? args[1] : []) : args,
        path: isApply ? path.slice(0, -1) : path,
      });
    },
  });

  return proxy;
}

/**
 * Creates a proxy that calls the callback with the path and arguments
 *
 * @internal
 */
export const createRecursiveProxy = (callback: ProxyCallback) =>
  createInnerProxy(callback, []);

/**
 * Used in place of `new Proxy` where each handler will map 1 level deep to another value.
 *
 * @internal
 */
export const createFlatProxy = <TFaux>(
  callback: (path: string & keyof TFaux) => any,
): TFaux => {
  return new Proxy(noop, {
    get(_obj, name) {
      if (typeof name !== 'string' || name === 'then') {
        // special case for if the proxy is accidentally treated
        // like a PromiseLike (like in `Promise.resolve(proxy)`)
        return undefined;
      }

      return callback(name as any);
    },
  }) as TFaux;
};
import { createFlatProxy, createRecursiveProxy } from '...';
import { get } from 'lodash';
import {
  type TypesaurusCore,
  type TypesaurusTransaction,
  batch as tsBatch,
  transaction as tsTransaction,
} from 'typesaurus';

export type PluginInterface = {
  name: string;
  beforeUpset?: (collection: string, id: string, data: object) => object;
  beforeUpdate?: (collection: string, id: string, data: object) => object;
  beforeSet?: (collection: string, id: string, data: object) => object;
  beforeAdd?: (collection: string, data: object) => object;
};

export const pluggable = <DB extends TypesaurusCore.DB<any>>(
  db: DB,
  plugins: PluginInterface[] = [],
) => {
  type ExtractSchemaType<T> = T extends TypesaurusCore.DB<infer U> ? U : never;
  type Schema = ExtractSchemaType<DB>;

  const dbProxy = <T extends {}>(name: string, db: T): T => {
    return createFlatProxy((key) => {
      return createRecursiveProxy(({ path, args }) => {
        const pathCopy = [key, ...path];
        const lastArg = pathCopy.pop()!;
        const obj = pathCopy.length === 0 ? db : get(db, pathCopy);
        const callable = obj?.[lastArg] && typeof obj[lastArg] === 'function';

        if (!callable) {
          throw new Error(`Invalid operation: ${lastArg}`);
        }

        // this is for batch commit, $.commit()
        if (lastArg === 'commit') {
          return obj[lastArg](...args);
        }

        const collection =
          pathCopy.length === 0 ? lastArg : pathCopy[pathCopy.length - 1];
        const isUpdate = callable && lastArg === 'update';
        const isSet = callable && lastArg === 'set';
        const isAdd = callable && lastArg === 'add';
        const isUpset = callable && lastArg === 'upset';

        // DEBUGGING: Leave these here for debugging purposes
        // console.log('------------------');
        // console.log('name', name);
        // console.log('collection', collection);
        // console.log('pathCopy', pathCopy);
        // console.log('lastArg', lastArg);
        // console.log('callable', callable);
        // console.log('------------------');

        // allow sub collection ex: db.users(db.users.id('u1')).tags.set(db.users.sub.tags.id('t1'), {} as any
        if (collection === lastArg) {
          return dbProxy(name, obj[lastArg](...args));
        }

        if (isUpdate) {
          const id = args[0] as string;
          const options = args[2] as object;
          const data = plugins.reduce(
            (acc, plugin) =>
              plugin.beforeUpdate
                ? plugin.beforeUpdate(collection!, id, acc)
                : acc,
            args[1] as object,
          );
          return obj[lastArg](id, data, options);
        }

        if (isSet) {
          const id = args[0] as string;
          const options = args[2] as object;
          const data = plugins.reduce(
            (acc, plugin) =>
              plugin.beforeSet ? plugin.beforeSet(collection!, id, acc) : acc,
            args[1] as object,
          );
          return obj[lastArg](id, data, options);
        }

        if (isUpset) {
          const id = args[0] as string;
          const options = args[2] as object;
          const data = plugins.reduce(
            (acc, plugin) =>
              plugin.beforeUpset
                ? plugin.beforeUpset(collection!, id, acc)
                : acc,
            args[1] as object,
          );
          return obj[lastArg](id, data, options);
        }

        if (isAdd) {
          const options = args[1] as object;
          const data = plugins.reduce(
            (acc, plugin) =>
              plugin.beforeAdd ? plugin.beforeAdd(collection!, acc) : acc,
            args[0] as object,
          );
          return obj[lastArg](data, options);
        }

        return obj[lastArg](...args);
      });
    });
  };

  const batch = (db: DB) => {
    const $ = tsBatch(db);
    type BatchType = typeof $ & { commit: () => Promise<void> };

    // FIXME: Users should use $.commit() instead of $() because we are using proxy
    ($ as BatchType).commit = async () => {
      return $();
    };

    return dbProxy('batch', $) as BatchType;
  };

  const transaction = <T extends TypesaurusCore.DB<any>>(db: T) => {
    const ts = tsTransaction(db);
    const $: any = {
      read(readCB: any): any {
        return {
          write(writeCB: any): any {
            return ts
              .read(($$: any) => {
                return readCB({
                  db: dbProxy('transaction-read', $$.db),
                });
              })
              .write(($$: any) => {
                return writeCB({
                  db: dbProxy('transaction-write', $$.db),
                  result: $$.result,
                });
              });
          },
        };
      },
    };

    return $ as TypesaurusTransaction.ReadChain<
      Schema,
      TypesaurusCore.DocProps
    >;
  };

  return {
    db: dbProxy('rootDb', db) as DB,
    batch: () => batch(db),
    transaction: () => transaction(db),
  };
};

sample plugin that takes zod schemas and adds timestamps

export const timeStampPlugin = (schemas: {
  [key: string]: z.ZodObject<any>;
}): PluginInterface => {
  const hasCreatedAt = (schema: z.ZodObject<any>) => {
    return typeof schema.shape.createdAt !== 'undefined';
  };

  const hasUpdatedAt = (schema: z.ZodObject<any>) => {
    return typeof schema.shape.updatedAt !== 'undefined';
  };

  return {
    name: 'timestamp',
    beforeUpdate: (collection: string, _id: string, data: any) => {
      const schema = getZodSchema(schemas, collection)!;
      if (hasUpdatedAt(schema)) {
        data.updatedAt = new Date().toISOString();
      }

      return data;
    },

    beforeUpset: (collection: string, _id: string, data: any) => {
      const schema = getZodSchema(schemas, collection)!;
      if (hasCreatedAt(schema)) {
        data.createdAt = new Date().toISOString();
      }

      if (hasUpdatedAt(schema)) {
        data.updatedAt = new Date().toISOString();
      }

      return data;
    },

    beforeSet: (collection: string, _id: string, data: any) => {
      const schema = getZodSchema(schemas, collection)!;
      if (hasCreatedAt(schema)) {
        data.createdAt = new Date().toISOString();
      }

      if (hasUpdatedAt(schema)) {
        data.updatedAt = new Date().toISOString();
      }

      return data;
    },

    beforeAdd: (collection: string, data: any) => {
      const schema = getZodSchema(schemas, collection)!;
      if (hasCreatedAt(schema)) {
        data.createdAt = new Date().toISOString();
      }

      if (hasUpdatedAt(schema)) {
        data.updatedAt = new Date().toISOString();
      }
      return data;
    },
  };
};

Usage:

import { pluggable } from './pluggable';
const { db, batch, transaction } = pluggable(baseDB, [
  timeStampPlugin(schemas),
]);
export { db, batch, transaction, schema };

@kossnocorp
Copy link
Owner

Hey! Thank you very much for sharing the code!

I started working on a plugin system to build an Effect integration.

So far, I have considered extending the methods' return type/value, but I can see the value in adding more ways to extend Typesaurus. I don't want to bloat the code with stuff no one will use.

Sharing your problem will help a lot. However, the code is a lot to take in, and I may have misinterpreted it. Can you please expand on what this code does exactly and what made you work on it? What exactly is missing that you want to achieve?

I want to understand your needs better so that the plugin system works for your case, too.

@kossnocorp kossnocorp changed the title Add hook/plugins functionality so we can tap into these methods set, upset, update, delete etc., Add plugin system May 14, 2024
@kossnocorp kossnocorp moved this to WIP in Open source May 14, 2024
@kossnocorp kossnocorp moved this to WIP in Typesaurus May 14, 2024
@mailaneel
Copy link
Author

Main things we want to do are, run time validations, add automatic timestamps, update version number of document, for all our models.

Code I shared hooks into update, upset, set and invoke plugins before* method with id, data, and options passed to the methods by user. This allows plugins to run validations/add more fields.

@kossnocorp kossnocorp moved this from WIP to Must in Open source Jun 13, 2024
@kossnocorp kossnocorp moved this from Must to Should in Open source Jun 13, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
Status: Should
Status: WIP
Development

No branches or pull requests

2 participants