kossnocorp / typesaurus

🦕 Type-safe TypeScript-first ODM for Firestore
https://typesaurus.com
412 stars 34 forks source link

Add plugin system #133

Open mailaneel opened 3 months ago

mailaneel commented 3 months ago

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

This allows basic functionality like

mailaneel commented 3 months ago

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 commented 3 months ago

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.

mailaneel commented 3 months ago

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.