convex-helpers
Version:
A collection of useful code to complement the official convex package.
506 lines (475 loc) • 15.5 kB
text/typescript
import type {
DocumentByName,
GenericDatabaseReader,
GenericDatabaseWriter,
GenericDataModel,
GenericMutationCtx,
NamedTableInfo,
QueryInitializer,
TableNamesInDataModel,
WithOptionalSystemFields,
WithoutSystemFields,
} from "convex/server";
import type { GenericId } from "convex/values";
/**
* This function will be called when a document in the table changes.
*/
export type Trigger<
Ctx,
DataModel extends GenericDataModel,
TableName extends TableNamesInDataModel<DataModel>,
> = (
ctx: Ctx & { innerDb: GenericDatabaseWriter<DataModel> },
change: Change<DataModel, TableName>,
) => Promise<void>;
export type Change<
DataModel extends GenericDataModel,
TableName extends TableNamesInDataModel<DataModel>,
> = {
id: GenericId<TableName>;
} & (
| {
operation: "insert";
oldDoc: null;
newDoc: DocumentByName<DataModel, TableName>;
}
| {
operation: "update";
oldDoc: DocumentByName<DataModel, TableName>;
newDoc: DocumentByName<DataModel, TableName>;
}
| {
operation: "delete";
oldDoc: DocumentByName<DataModel, TableName>;
newDoc: null;
}
);
/**
* Construct Triggers to register functions that run whenever a table changes.
* Sample usage:
*
* ```
* import { mutation as rawMutation } from "./_generated/server";
* import { DataModel } from "./_generated/dataModel";
* import { Triggers } from "convex-helpers/server/triggers";
* import { customCtx, customMutation } from "convex-helpers/server/customFunctions";
*
* const triggers = new Triggers<DataModel>();
* triggers.register("myTableName", async (ctx, change) => {
* console.log("Table changed", change);
* });
*
* // Use `mutation` to define all mutations, and the triggers will get called.
* export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB));
* ```
*/
export class Triggers<
DataModel extends GenericDataModel,
Ctx extends {
db: GenericDatabaseWriter<DataModel>;
} = GenericMutationCtx<DataModel>,
> {
registered: {
[TableName in TableNamesInDataModel<DataModel>]?: Trigger<
Ctx,
DataModel,
TableName
>[];
} = {};
register<TableName extends TableNamesInDataModel<DataModel>>(
tableName: TableName,
trigger: Trigger<Ctx, DataModel, TableName>,
) {
if (!this.registered[tableName]) {
this.registered[tableName] = [];
}
this.registered[tableName]!.push(trigger);
}
wrapDB = <C extends Ctx>(ctx: C): C => {
return { ...ctx, db: writerWithTriggers(ctx, ctx.db, this) };
};
}
class Lock {
promise: Promise<void> | null = null;
resolve: (() => void) | null = null;
async withLock<R>(f: () => Promise<R>): Promise<R> {
const unlock = await this._lock();
try {
return await f();
} finally {
unlock();
}
}
async _lock(): Promise<() => void> {
while (this.promise !== null) {
await this.promise;
}
[this.promise, this.resolve] = this._newLock();
return () => {
this.promise = null;
this.resolve?.();
};
}
_newLock(): [Promise<void>, () => void] {
let resolve: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return [promise, () => resolve()];
}
}
/**
* Locking semantics:
* - Database writes to tables with triggers are serialized with
* `innerWriteLock` so we can calculate the `change` object without
* interference from parallel writes.
* - When the application (not a trigger) calls `insert`, `patch`, or `replace`,
* it will acquire the outer write lock and hold it while doing the write
* operation and all subsequent triggers, including recursive triggers.
* - This ensures atomicity in the simple case where a trigger doesn't call
* other triggers recursively.
* - Recursive triggers are queued up, so they are executed in the same order
* as the database writes were. At a high level, this is a BFS traversal of
* the trigger graph.
* - Note when there are multiple triggers, they can't be executed atomically
* with the writes that caused them, from the perspective of the other
* triggers. So if one trigger is making sure denormalized data is
* consistent, another trigger could see the data in an inconsistent state.
* To avoid such problems, triggers should be resilient to such
* inconsistencies or the trigger graph should be kept simple.
*/
const innerWriteLock = new Lock();
const outerWriteLock = new Lock();
const triggerQueue: (() => Promise<void>)[] = [];
/** @deprecated use writerWithTriggers instead */
export class DatabaseWriterWithTriggers<
DataModel extends GenericDataModel,
Ctx extends {
db: GenericDatabaseWriter<DataModel>;
} = GenericMutationCtx<DataModel>,
> implements GenericDatabaseWriter<DataModel>
{
writer: GenericDatabaseWriter<DataModel>;
constructor(
ctx: Ctx,
innerDb: GenericDatabaseWriter<DataModel>,
triggers: Triggers<DataModel, Ctx>,
isWithinTrigger: boolean = false,
) {
this.system = innerDb.system;
this.writer = writerWithTriggers(ctx, innerDb, triggers, isWithinTrigger);
}
delete<TableName extends TableNamesInDataModel<DataModel>>(
table: NonUnion<TableName>,
id: GenericId<TableName>,
): Promise<void>;
delete(id: GenericId<TableNamesInDataModel<DataModel>>): Promise<void>;
delete(arg0: any, arg1?: any): Promise<void> {
return this.writer.delete(arg0, arg1);
}
get<TableName extends TableNamesInDataModel<DataModel>>(
table: NonUnion<TableName>,
id: GenericId<TableName>,
): Promise<DocumentByName<DataModel, TableName> | null>;
get<TableName extends TableNamesInDataModel<DataModel>>(
id: GenericId<TableName>,
): Promise<DocumentByName<DataModel, TableName> | null>;
get(arg0: any, arg1?: any) {
return this.writer.get(arg0, arg1);
}
insert<TableName extends TableNamesInDataModel<DataModel>>(
table: TableName,
value: WithoutSystemFields<DocumentByName<DataModel, TableName>>,
): Promise<GenericId<TableName>> {
return this.writer.insert(table, value);
}
patch<TableName extends TableNamesInDataModel<DataModel>>(
table: NonUnion<TableName>,
id: GenericId<TableName>,
value: PatchValue<DocumentByName<DataModel, TableName>>,
): Promise<void>;
patch<TableName extends TableNamesInDataModel<DataModel>>(
id: GenericId<TableName>,
value: PatchValue<DocumentByName<DataModel, TableName>>,
): Promise<void>;
patch(arg0: any, arg1: any, arg2?: any): Promise<void> {
return this.writer.patch(arg0, arg1, arg2);
}
query<TableName extends TableNamesInDataModel<DataModel>>(
tableName: TableName,
): QueryInitializer<NamedTableInfo<DataModel, TableName>> {
return this.writer.query(tableName);
}
normalizeId<TableName extends TableNamesInDataModel<DataModel>>(
tableName: TableName,
id: string,
): GenericId<TableName> | null {
return this.writer.normalizeId(tableName, id);
}
replace<TableName extends TableNamesInDataModel<DataModel>>(
table: NonUnion<TableName>,
id: GenericId<TableName>,
value: WithOptionalSystemFields<DocumentByName<DataModel, TableName>>,
): Promise<void>;
replace<TableName extends TableNamesInDataModel<DataModel>>(
id: GenericId<TableName>,
value: WithOptionalSystemFields<DocumentByName<DataModel, TableName>>,
): Promise<void>;
replace(arg0: any, arg1: any, arg2?: any): Promise<void> {
return this.writer.replace(arg0, arg1, arg2);
}
system: GenericDatabaseWriter<DataModel>["system"];
}
export function writerWithTriggers<
DataModel extends GenericDataModel,
Ctx extends {
db: GenericDatabaseWriter<DataModel>;
} = GenericMutationCtx<DataModel>,
>(
ctx: Ctx,
innerDb: GenericDatabaseWriter<DataModel>,
triggers: Triggers<DataModel, Ctx>,
isWithinTrigger: boolean = false,
): GenericDatabaseWriter<DataModel> {
const patch: {
<TableName extends TableNamesInDataModel<DataModel>>(
table: NonUnion<TableName>,
id: GenericId<TableName>,
value: PatchValue<DocumentByName<DataModel, TableName>>,
): Promise<void>;
<TableName extends TableNamesInDataModel<DataModel>>(
id: GenericId<TableName>,
value: PatchValue<DocumentByName<DataModel, TableName>>,
): Promise<void>;
} = async (arg0: any, arg1: any, arg2?: any) => {
const [tableName, id, value] =
arg2 !== undefined
? [arg0, arg1, arg2]
: [_tableNameFromId(innerDb, triggers.registered, arg0), arg0, arg1];
return await _patch(tableName, id, value);
};
async function _patch<TableName extends TableNamesInDataModel<DataModel>>(
tableName: TableName | null,
id: GenericId<TableName>,
value: Partial<DocumentByName<DataModel, TableName>>,
): Promise<void> {
if (!tableName) {
return await innerDb.patch(id, value);
}
return await _execThenTrigger(
ctx,
innerDb,
triggers,
tableName,
isWithinTrigger,
async () => {
const oldDoc = (await innerDb.get(id))!;
await innerDb.patch(tableName, id, value);
const newDoc = (await innerDb.get(id))!;
return [undefined, { operation: "update", id, oldDoc, newDoc }];
},
);
}
const replace: {
<TableName extends TableNamesInDataModel<DataModel>>(
table: NonUnion<TableName>,
id: GenericId<TableName>,
value: WithOptionalSystemFields<DocumentByName<DataModel, TableName>>,
): Promise<void>;
<TableName extends TableNamesInDataModel<DataModel>>(
id: GenericId<TableName>,
value: WithOptionalSystemFields<DocumentByName<DataModel, TableName>>,
): Promise<void>;
} = async (arg0: any, arg1: any, arg2?: any) => {
const [tableName, id, value] =
arg2 !== undefined
? [arg0, arg1, arg2]
: [_tableNameFromId(innerDb, triggers.registered, arg0), arg0, arg1];
return await _replace(tableName, id, value);
};
async function _replace<TableName extends TableNamesInDataModel<DataModel>>(
tableName: TableName | null,
id: GenericId<TableName>,
value: WithOptionalSystemFields<DocumentByName<DataModel, TableName>>,
): Promise<void> {
if (!tableName) {
return await innerDb.replace(id, value);
}
return await _execThenTrigger(
ctx,
innerDb,
triggers,
tableName,
isWithinTrigger,
async () => {
const oldDoc = (await innerDb.get(id))!;
await innerDb.replace(tableName, id, value);
const newDoc = (await innerDb.get(id))!;
return [undefined, { operation: "update", id, oldDoc, newDoc }];
},
);
}
const delete_: {
<TableName extends TableNamesInDataModel<DataModel>>(
table: NonUnion<TableName>,
id: GenericId<TableName>,
): Promise<void>;
(id: GenericId<TableNamesInDataModel<DataModel>>): Promise<void>;
} = async (arg0: any, arg1?: any) => {
const [tableName, id] =
arg1 !== undefined
? [arg0, arg1]
: [_tableNameFromId(innerDb, triggers.registered, arg0), arg0];
return await _delete(tableName, id);
};
async function _delete<TableName extends TableNamesInDataModel<DataModel>>(
tableName: TableName | null,
id: GenericId<TableNamesInDataModel<DataModel>>,
): Promise<void> {
if (!tableName) {
return await innerDb.delete(id);
}
return await _execThenTrigger(
ctx,
innerDb,
triggers,
tableName,
isWithinTrigger,
async () => {
const oldDoc = (await innerDb.get(id))!;
await innerDb.delete(tableName, id);
return [undefined, { operation: "delete", id, oldDoc, newDoc: null }];
},
);
}
return {
insert: async <TableName extends TableNamesInDataModel<DataModel>>(
table: TableName,
value: WithoutSystemFields<DocumentByName<DataModel, TableName>>,
): Promise<GenericId<TableName>> => {
if (!triggers.registered[table]) {
return await innerDb.insert(table, value);
}
return await _execThenTrigger(
ctx,
innerDb,
triggers,
table,
isWithinTrigger,
async () => {
const id = await innerDb.insert(table, value);
const newDoc = (await innerDb.get(id))!;
return [id, { operation: "insert", id, oldDoc: null, newDoc }];
},
);
},
patch,
replace,
delete: delete_,
system: innerDb.system,
get: innerDb.get,
query: innerDb.query,
normalizeId: innerDb.normalizeId,
};
}
// Helper methods.
function _tableNameFromId<
DataModel extends GenericDataModel,
TableName extends TableNamesInDataModel<DataModel>,
Ctx extends {
db: GenericDatabaseWriter<DataModel>;
} = GenericMutationCtx<DataModel>,
>(
db: GenericDatabaseReader<DataModel>,
registered: Triggers<DataModel, Ctx>["registered"],
id: GenericId<TableName>,
): TableName | null {
for (const tableName of Object.keys(registered)) {
if (db.normalizeId(tableName as TableNamesInDataModel<DataModel>, id)) {
return tableName as TableName;
}
}
return null;
}
async function _queueTriggers<
DataModel extends GenericDataModel,
R,
TableName extends TableNamesInDataModel<DataModel>,
Ctx extends {
db: GenericDatabaseWriter<DataModel>;
} = GenericMutationCtx<DataModel>,
>(
ctx: Ctx,
innerDb: GenericDatabaseWriter<DataModel>,
triggers: Triggers<DataModel, Ctx>,
tableName: TableName,
f: () => Promise<[R, Change<DataModel, TableName>]>,
): Promise<R> {
return await innerWriteLock.withLock(async () => {
const [result, change] = await f();
const recursiveCtx = {
...ctx,
db: writerWithTriggers(ctx, innerDb, triggers, true),
innerDb: innerDb,
};
for (const trigger of triggers.registered[tableName] ?? []) {
triggerQueue.push(async () => {
await trigger(recursiveCtx, change);
});
}
return result;
});
}
async function _execThenTrigger<
DataModel extends GenericDataModel,
R,
TableName extends TableNamesInDataModel<DataModel>,
Ctx extends {
db: GenericDatabaseWriter<DataModel>;
} = GenericMutationCtx<DataModel>,
>(
ctx: Ctx,
innerDb: GenericDatabaseWriter<DataModel>,
triggers: Triggers<DataModel, Ctx>,
tableName: TableName,
isWithinTrigger: boolean,
f: () => Promise<[R, Change<DataModel, TableName>]>,
): Promise<R> {
if (isWithinTrigger) {
return await _queueTriggers(ctx, innerDb, triggers, tableName, f);
}
return await outerWriteLock.withLock(async () => {
const result = await _queueTriggers(ctx, innerDb, triggers, tableName, f);
let e: unknown | null = null;
while (triggerQueue.length > 0) {
const trigger = triggerQueue.shift()!;
try {
await trigger();
} catch (err) {
if (e) {
console.error(err);
} else {
e = err;
}
}
}
if (e !== null) {
throw e;
}
return result;
});
}
/**
* This prevents TypeScript from inferring that the generic `TableName` type is
* a union type when `table` and `id` disagree.
*/
type NonUnion<T> = T extends never // `never` is the bottom type for TypeScript unions
? never
: T;
/**
* This is like Partial, but it also allows undefined to be passed to optional
* fields when `exactOptionalPropertyTypes` is enabled in the tsconfig.
*/
type PatchValue<T> = {
[P in keyof T]?: undefined extends T[P] ? T[P] | undefined : T[P];
};