@clickup/ent-framework
Version:
A PostgreSQL graph-database-alike library with microsharding and row-level security
397 lines (350 loc) • 12.4 kB
text/typescript
import { deepEqual } from "../internal/deepEqual";
import type { Flatten, Writeable } from "../internal/misc";
import { join } from "../internal/misc";
import type {
InsertInput,
Row,
RowWithID,
Table,
UpdateInput,
Value,
} from "../types";
import type { VC } from "./VC";
/**
* Table -> trigger's before- and after-insert input. Below, we use InsertInput
* and not Row, because before and even after some INSERT, we may still not know
* some values of the row (they can be filled by the DB in e.g. autoInsert
* clause). InsertInput is almost a subset of Row, but it has stricter symbol
* keys: e.g. if some symbol key is non-optional in INSERT (aka doesn't have
* autoInsert), it will always be required in InsertInput too.
*/
export type TriggerInsertInput<TTable extends Table> = Flatten<
InsertInput<TTable> & RowWithID
>;
/**
* Table -> trigger's before-update input.
*/
export type TriggerUpdateInput<TTable extends Table> = Flatten<
UpdateInput<TTable>
>;
/**
* Table -> trigger's before- and after-update NEW row. Ephemeral (symbol)
* fields may or may not be passed depending on what the user passes to the
* update method.
*/
export type TriggerUpdateNewRow<TTable extends Table> = Flatten<
Readonly<
Row<TTable> & {
[K in keyof TTable & symbol]?: Value<TTable[K]> | undefined;
}
>
>;
/**
* Table -> trigger's before- and after-update (or delete) OLD row. Ephemeral
* (symbol) fields are marked as always presented, but "never" typed, so they
* will be available for dereferencing in newOrOldRow of before/after mutation
* triggers without guard-checking of op value.
*/
export type TriggerUpdateOrDeleteOldRow<TTable extends Table> = Flatten<
Readonly<Row<TTable> & Record<keyof TTable & symbol, never>>
>;
/**
* Triggers could be used to simulate "transactional best-effort behavior" in a
* non-transactional combination of some services. Imagine we have a relational
* database and a queue service; each time we change something in the query, we
* want to schedule the ID to the queue. Queue service is faulty: if a queueing
* operation fails, we don't want the data to be stored to the DB afterwards.
* Queries are faulty too, but it's okay for us to have something added to the
* queue even if the corresponding query failed after it (a queue worker will
* just do a no-op since it anyway rechecks the source of truth in relational
* DBs). Queue service is like a write-ahead log for DB which always has
* not-less records than the DB. In this case, we have the following set of
* triggers:
*
* 1. beforeInsert: schedules ID to the queue (ID is known, see below why)
* 2. beforeUpdate: schedules ID to the queue
* 3. afterDelete: optionally schedule ID removal to the queue (notice "after")
*
* Notice that ID is always known in all cases, even in insertBefore triggers,
* because we split an INSERT operation into gen_id+insert parts, and the
* triggers are executed in between.
*
* Triggers are invoked sequentially. Any exception thrown in a before-trigger
* is propagated to the caller, and the DB operation is skipped.
*
* Triggers for beforeInsert and beforeUpdate can change their input parameter,
* the change will apply to the database.
*
* Naming convention for trigger arguments:
* 1. input: whatever is passed to the operation. Notice that due to us having
* autoInsert/autoUpdate fields, the set of fields can be incomplete here!
* 1. oldRow: the entire row in the DB which was there before the operation. All
* the fields will be presented there.
* 2. newRow: a row in the DB as it will looks like after the operation. Notice
* that it can be non precise, because we don't always reload the updated row
* from the database! What we do is just field by field application of input
* properties to oldRow.
*/
export type InsertTrigger<TTable extends Table> = (
vc: VC,
args: { input: TriggerInsertInput<TTable> }, // always knows ID even in beforeInsert
) => Promise<unknown> | unknown;
export type BeforeUpdateTrigger<TTable extends Table> = (
vc: VC,
args: {
newRow: TriggerUpdateNewRow<TTable>;
oldRow: TriggerUpdateOrDeleteOldRow<TTable>;
input: TriggerUpdateInput<TTable>;
},
) => Promise<unknown> | unknown;
export type AfterUpdateTrigger<TTable extends Table> = (
vc: VC,
args: {
newRow: TriggerUpdateNewRow<TTable>;
oldRow: TriggerUpdateOrDeleteOldRow<TTable>;
},
) => Promise<unknown> | unknown;
export type DeleteTrigger<TTable extends Table> = (
vc: VC,
args: {
oldRow: TriggerUpdateOrDeleteOldRow<TTable>;
},
) => Promise<unknown> | unknown;
export type BeforeMutationTrigger<TTable extends Table> = (
vc: VC,
args:
| {
op: "INSERT";
newOrOldRow: Readonly<TriggerInsertInput<TTable>>;
input: TriggerInsertInput<TTable>;
}
| {
op: "UPDATE";
newOrOldRow: TriggerUpdateNewRow<TTable>;
input: TriggerUpdateInput<TTable>;
}
| {
op: "DELETE";
newOrOldRow: TriggerUpdateOrDeleteOldRow<TTable>;
/** We allow people to modify input of a DELETE operation, although it
* will be a no-op. This is for convenience: if we remained it
* read-only, then people would need to check `if (op !== "DELETE") ...`
* in their beforeMutation triggers, which is a boilerplate. */
input: Writeable<TriggerUpdateOrDeleteOldRow<TTable>>;
},
) => Promise<unknown> | unknown;
export type AfterMutationTrigger<TTable extends Table> = (
vc: VC,
args:
| {
op: "INSERT";
newOrOldRow: Readonly<TriggerInsertInput<TTable>>;
}
| {
op: "UPDATE";
newOrOldRow: TriggerUpdateNewRow<TTable>;
}
| {
op: "DELETE";
newOrOldRow: TriggerUpdateOrDeleteOldRow<TTable>;
},
) => Promise<unknown> | unknown;
export type DepsBuilder<TTable extends Table> = (
vc: VC,
row: Flatten<Readonly<Row<TTable>>>,
) => unknown[] | Promise<unknown[]>;
export class Triggers<TTable extends Table> {
constructor(
private beforeInsert: Array<InsertTrigger<TTable>>,
private beforeUpdate: Array<
[DepsBuilder<TTable> | null, BeforeUpdateTrigger<TTable>]
>,
private beforeDelete: Array<DeleteTrigger<TTable>>,
private beforeMutation: Array<
[DepsBuilder<TTable> | null, BeforeMutationTrigger<TTable>]
>,
private afterInsert: Array<InsertTrigger<TTable>>,
private afterUpdate: Array<
[DepsBuilder<TTable> | null, AfterUpdateTrigger<TTable>]
>,
private afterDelete: Array<DeleteTrigger<TTable>>,
private afterMutation: Array<
[DepsBuilder<TTable> | null, AfterMutationTrigger<TTable>]
>,
) {}
hasInsertTriggers(): boolean {
return (
this.beforeInsert.length > 0 ||
this.beforeMutation.length > 0 ||
this.afterInsert.length > 0 ||
this.afterMutation.length > 0
);
}
hasUpdateTriggers(): boolean {
return (
this.beforeUpdate.length > 0 ||
this.beforeMutation.length > 0 ||
this.afterUpdate.length > 0 ||
this.afterMutation.length > 0
);
}
async wrapInsert(
func: (input: InsertInput<TTable> & RowWithID) => Promise<string | null>,
vc: VC,
input: InsertInput<TTable> & RowWithID,
): Promise<string | null> {
if (!this.hasInsertTriggers()) {
return func(input);
}
for (const triggerBeforeInsert of this.beforeInsert) {
// We clone the input to make different triggers calls independent: if the
// trigger e.g. stores input somewhere by reference, we don't want the
// next trigger to affect that place.
input = { ...input };
await triggerBeforeInsert(vc, { input });
}
for (const [_, triggerBeforeMutation] of this.beforeMutation) {
input = { ...input };
await triggerBeforeMutation(vc, {
op: "INSERT",
newOrOldRow: input,
input,
});
}
const output = await func(input);
if (!output) {
// Insert failed (unique key constraint failed); don't run after-triggers.
return output;
}
for (const triggerAfterInsert of this.afterInsert) {
await triggerAfterInsert(vc, { input });
}
for (const [_, triggerAfterMutation] of this.afterMutation) {
await triggerAfterMutation(vc, { op: "INSERT", newOrOldRow: input });
}
return output;
}
async wrapUpdate(
func: (input: UpdateInput<TTable>) => Promise<boolean>,
vc: VC,
oldRow: TriggerUpdateOrDeleteOldRow<TTable>,
input: UpdateInput<TTable>,
): Promise<boolean> {
if (!this.hasUpdateTriggers()) {
return func(input);
}
let newRow = buildUpdateNewRow(oldRow, input);
for (const [depsBuilder, triggerBeforeUpdate] of this.beforeUpdate) {
if (await depsBuilderApproves(depsBuilder, vc, oldRow, newRow)) {
await triggerBeforeUpdate(vc, { newRow, oldRow, input });
// Each call to triggerBefore() may potentially change the input, so we
// need to rebuild newRow each time to feed it to the next call of
// triggerBefore() and to the rest of triggerAfter.
newRow = buildUpdateNewRow(oldRow, input);
}
}
for (const [depsBuilder, triggerBeforeMutation] of this.beforeMutation) {
if (await depsBuilderApproves(depsBuilder, vc, oldRow, newRow)) {
await triggerBeforeMutation(vc, {
op: "UPDATE",
newOrOldRow: newRow,
input,
});
newRow = buildUpdateNewRow(oldRow, input);
}
}
const output = await func(input);
if (!output) {
// Update failed (no row with such ID); don't call after-triggers.
return output;
}
for (const [depsBuilder, triggerAfterUpdate] of this.afterUpdate) {
if (await depsBuilderApproves(depsBuilder, vc, oldRow, newRow)) {
await triggerAfterUpdate(vc, {
newRow,
oldRow: oldRow as TriggerUpdateOrDeleteOldRow<TTable>,
});
}
}
for (const [depsBuilder, triggerAfterMutation] of this.afterMutation) {
if (await depsBuilderApproves(depsBuilder, vc, oldRow, newRow)) {
await triggerAfterMutation(vc, {
op: "UPDATE",
newOrOldRow: newRow,
});
}
}
return output;
}
async wrapDelete(
func: () => Promise<boolean>,
vc: VC,
oldRow: TriggerUpdateOrDeleteOldRow<TTable>,
): Promise<boolean> {
for (const triggerBeforeDelete of this.beforeDelete) {
await triggerBeforeDelete(vc, { oldRow });
}
for (const [_, triggerBeforeMutation] of this.beforeMutation) {
await triggerBeforeMutation(vc, {
op: "DELETE",
newOrOldRow: oldRow,
input: oldRow,
});
}
const output = await func();
if (!output) {
// Delete failed (no row with such ID); don't call after-triggers.
return output;
}
for (const triggerAfterDelete of this.afterDelete) {
await triggerAfterDelete(vc, { oldRow });
}
for (const [_, triggerAfterMutation] of this.afterMutation) {
await triggerAfterMutation(vc, {
op: "DELETE",
newOrOldRow: oldRow,
});
}
return output;
}
}
/**
* Simulates an update for a row, as if it's applied to the Ent.
* @ignore
*/
export function buildUpdateNewRow<TTable extends Table>(
oldRow: Row<TTable>,
input: UpdateInput<TTable>,
): TriggerUpdateNewRow<TTable> {
const newRow = { ...oldRow } as TriggerUpdateNewRow<TTable>;
for (const k of Object.getOwnPropertyNames(input)) {
if (input[k] !== undefined) {
(newRow as Record<string, unknown>)[k] = input[k];
}
}
for (const k of Object.getOwnPropertySymbols(input)) {
if (input[k] !== undefined) {
(newRow as Record<symbol, unknown>)[k] = input[k];
}
}
return newRow;
}
/**
* Returns true if depsBuilder approves running the trigger (i.e. the deps are
* changed, or no depsBuilder is presented at all).
*/
async function depsBuilderApproves<TTable extends Table>(
depsBuilder: DepsBuilder<TTable> | null,
vc: VC,
oldRow: Row<TTable>,
newRow: Row<TTable>,
): Promise<boolean> {
if (!depsBuilder) {
return true;
}
const [depsOld, depsNew] = await join([
depsBuilder(vc, oldRow),
depsBuilder(vc, newRow),
]);
return !deepEqual(depsOld, depsNew);
}