Open mailaneel opened 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 };
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.
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.
Add hook/plugins functionality so we can tap into these methods set, upset, update, delete etc.,
This allows basic functionality like