mirror of
https://github.com/outline/outline.git
synced 2026-05-18 16:48:46 -05:00
879c568a2c
* Upgrade Prettier to v3.6.2 and eslint-plugin-prettier to v5.5.1 - Upgraded prettier from ^2.8.8 to ^3.6.2 (latest version) - Upgraded eslint-plugin-prettier from ^4.2.1 to ^5.5.1 for compatibility - Applied automatic formatting changes from new Prettier version - All existing ESLint and Prettier configurations remain compatible * Applied automatic fixes * Trigger CI --------- Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com> Co-authored-by: Tom Moor <tom@getoutline.com>
435 lines
11 KiB
TypeScript
435 lines
11 KiB
TypeScript
import isEqual from "fast-deep-equal";
|
|
import isArray from "lodash/isArray";
|
|
import isObject from "lodash/isObject";
|
|
import pick from "lodash/pick";
|
|
import {
|
|
Attributes,
|
|
CreationAttributes,
|
|
DataTypes,
|
|
FindOptions,
|
|
FindOrCreateOptions,
|
|
ModelStatic,
|
|
NonAttribute,
|
|
SaveOptions,
|
|
} from "sequelize";
|
|
import {
|
|
AfterCreate,
|
|
AfterDestroy,
|
|
AfterRestore,
|
|
AfterUpdate,
|
|
AfterUpsert,
|
|
BeforeSave,
|
|
Model as SequelizeModel,
|
|
} from "sequelize-typescript";
|
|
import Logger from "@server/logging/Logger";
|
|
import { Replace, APIContext } from "@server/types";
|
|
import { getChangsetSkipped } from "../decorators/Changeset";
|
|
|
|
type EventOverrideOptions = {
|
|
/** Override the default event name. */
|
|
name?: string;
|
|
/** Additional data to publish in the event. */
|
|
data?: Record<string, unknown>;
|
|
/**
|
|
* Whether to persist the event to the database. Defaults to true when using any `withCtx` methods.
|
|
*/
|
|
persist?: boolean;
|
|
};
|
|
|
|
type EventOptions = EventOverrideOptions & {
|
|
/**
|
|
* Whether to publish event to the job queue. Defaults to true when using any `withCtx` methods.
|
|
*/
|
|
publish: boolean;
|
|
};
|
|
|
|
export type HookContext = APIContext["context"] & { event?: EventOptions };
|
|
|
|
class Model<
|
|
TModelAttributes extends object = any,
|
|
TCreationAttributes extends object = TModelAttributes,
|
|
> extends SequelizeModel<TModelAttributes, TCreationAttributes> {
|
|
/**
|
|
* The namespace to use for events - defaults to the table name if none is provided.
|
|
*/
|
|
static eventNamespace: string | undefined;
|
|
|
|
/**
|
|
* Validates this instance, and if the validation passes, persists it to the database.
|
|
*/
|
|
public saveWithCtx<M extends Model>(
|
|
ctx: APIContext,
|
|
options?: SaveOptions<Attributes<M>>,
|
|
eventOpts?: EventOverrideOptions
|
|
) {
|
|
const hookContext: HookContext = {
|
|
...ctx.context,
|
|
event: {
|
|
...eventOpts,
|
|
publish: true,
|
|
},
|
|
};
|
|
return this.save({ ...options, ...hookContext });
|
|
}
|
|
|
|
/**
|
|
* This is the same as calling `set` and then calling `save`.
|
|
*/
|
|
public updateWithCtx(
|
|
ctx: APIContext,
|
|
keys: Partial<TModelAttributes>,
|
|
eventOpts?: EventOverrideOptions
|
|
) {
|
|
const hookContext: HookContext = {
|
|
...ctx.context,
|
|
event: {
|
|
...eventOpts,
|
|
publish: true,
|
|
},
|
|
};
|
|
this.set(keys);
|
|
return this.save(hookContext);
|
|
}
|
|
|
|
/**
|
|
* Destroy the row corresponding to this instance. Depending on your setting for paranoid, the row will
|
|
* either be completely deleted, or have its deletedAt timestamp set to the current time.
|
|
*/
|
|
public destroyWithCtx(ctx: APIContext, eventOpts?: EventOverrideOptions) {
|
|
const hookContext: HookContext = {
|
|
...ctx.context,
|
|
event: {
|
|
...eventOpts,
|
|
publish: true,
|
|
},
|
|
};
|
|
return this.destroy(hookContext);
|
|
}
|
|
|
|
/**
|
|
* Restore the row corresponding to this instance. Only available for paranoid models.
|
|
*/
|
|
public restoreWithCtx(ctx: APIContext, eventOpts?: EventOverrideOptions) {
|
|
const hookContext: HookContext = {
|
|
...ctx.context,
|
|
event: {
|
|
...eventOpts,
|
|
publish: true,
|
|
},
|
|
};
|
|
return this.restore(hookContext);
|
|
}
|
|
|
|
/**
|
|
* Find a row that matches the query, or build and save the row if none is found
|
|
* The successful result of the promise will be (instance, created) - Make sure to use `.then(([...]))`
|
|
*/
|
|
public static findOrCreateWithCtx<M extends Model>(
|
|
this: ModelStatic<M>,
|
|
ctx: APIContext,
|
|
options: FindOrCreateOptions<Attributes<M>, CreationAttributes<M>>,
|
|
eventOpts?: EventOverrideOptions
|
|
) {
|
|
const hookContext: HookContext = {
|
|
...ctx.context,
|
|
event: {
|
|
...eventOpts,
|
|
publish: true,
|
|
},
|
|
};
|
|
return this.findOrCreate({
|
|
...options,
|
|
...hookContext,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Builds a new model instance and calls save on it.
|
|
*/
|
|
public static createWithCtx<M extends Model>(
|
|
this: ModelStatic<M>,
|
|
ctx: APIContext,
|
|
values?: CreationAttributes<M>,
|
|
eventOpts?: EventOverrideOptions
|
|
) {
|
|
const hookContext: HookContext = {
|
|
...ctx.context,
|
|
event: {
|
|
...eventOpts,
|
|
publish: true,
|
|
},
|
|
};
|
|
return this.create(values, hookContext);
|
|
}
|
|
|
|
@BeforeSave
|
|
static async beforeSaveEvent<T extends Model>(model: T) {
|
|
model.cacheChangeset();
|
|
}
|
|
|
|
@AfterCreate
|
|
static async afterCreateEvent<T extends Model>(
|
|
model: T,
|
|
context: HookContext
|
|
) {
|
|
await this.insertEvent("create", model, context);
|
|
}
|
|
|
|
@AfterUpsert
|
|
static async afterUpsertEvent<T extends Model>(
|
|
model: T,
|
|
context: HookContext
|
|
) {
|
|
await this.insertEvent("create", model, context);
|
|
}
|
|
|
|
@AfterUpdate
|
|
static async afterUpdateEvent<T extends Model>(
|
|
model: T,
|
|
context: HookContext
|
|
) {
|
|
await this.insertEvent("update", model, context);
|
|
}
|
|
|
|
@AfterDestroy
|
|
static async afterDestroyEvent<T extends Model>(
|
|
model: T,
|
|
context: HookContext
|
|
) {
|
|
await this.insertEvent("delete", model, context);
|
|
}
|
|
|
|
@AfterRestore
|
|
static async afterRestoreEvent<T extends Model>(
|
|
model: T,
|
|
context: HookContext
|
|
) {
|
|
await this.insertEvent("create", model, context);
|
|
}
|
|
|
|
/**
|
|
* Insert an event into the database recording a mutation to this model.
|
|
*
|
|
* @param name The name of the event.
|
|
* @param model The model that was mutated.
|
|
* @param context The API context.
|
|
*/
|
|
protected static async insertEvent<T extends Model>(
|
|
name: string,
|
|
model: T,
|
|
context: HookContext
|
|
) {
|
|
const namespace = this.eventNamespace ?? this.tableName;
|
|
const models = this.sequelize!.models;
|
|
|
|
if (!context.event?.publish) {
|
|
return;
|
|
}
|
|
|
|
if (!context.transaction) {
|
|
Logger.warn("No transaction provided to insertEvent", {
|
|
modelId: model.id,
|
|
});
|
|
}
|
|
|
|
if (!context.ip) {
|
|
Logger.warn("No ip provided to insertEvent", {
|
|
modelId: model.id,
|
|
});
|
|
}
|
|
|
|
const attrs = {
|
|
name: `${namespace}.${context.event.name ?? name}`,
|
|
modelId: "modelId" in model ? model.modelId : model.id,
|
|
collectionId:
|
|
"collectionId" in model
|
|
? model.collectionId
|
|
: model instanceof models.collection
|
|
? model.id
|
|
: undefined,
|
|
documentId:
|
|
"documentId" in model
|
|
? model.documentId
|
|
: model instanceof models.document
|
|
? model.id
|
|
: undefined,
|
|
userId:
|
|
"userId" in model
|
|
? model.userId
|
|
: model instanceof models.user
|
|
? model.id
|
|
: undefined,
|
|
teamId:
|
|
"teamId" in model
|
|
? model.teamId
|
|
: model instanceof models.team
|
|
? model.id
|
|
: context.auth?.user.teamId,
|
|
actorId: context.auth?.user?.id,
|
|
authType: context.auth?.type,
|
|
ip: context.ip,
|
|
changes: model.previousChangeset,
|
|
data: context.event.data,
|
|
};
|
|
|
|
if (context.event?.persist !== false) {
|
|
return models.event.create(attrs, {
|
|
transaction: context.transaction,
|
|
});
|
|
} else if (context.transaction) {
|
|
(context.transaction.parent || context.transaction).afterCommit(() =>
|
|
// @ts-expect-error Event class
|
|
models.event.schedule(attrs)
|
|
);
|
|
} else {
|
|
// @ts-expect-error Event class
|
|
return models.event.schedule(attrs);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find all models in batches, calling the callback function for each batch.
|
|
*
|
|
* @param query The query options.
|
|
* @param callback The function to call for each batch of results
|
|
* @return The total number of results processed.
|
|
*/
|
|
static async findAllInBatches<T extends Model>(
|
|
query: Replace<FindOptions<T>, "limit", "batchLimit"> & {
|
|
/** The maximum number of results to return, after which the query will stop. */
|
|
totalLimit?: number;
|
|
},
|
|
callback: (results: Array<T>, query: FindOptions<T>) => Promise<void>
|
|
): Promise<number> {
|
|
let total = 0;
|
|
const mappedQuery = {
|
|
...query,
|
|
offset: query.offset ?? 0,
|
|
limit: query.batchLimit ?? 10,
|
|
};
|
|
|
|
let results;
|
|
|
|
do {
|
|
// @ts-expect-error this T
|
|
results = await this.findAll<T>(mappedQuery);
|
|
total += results.length;
|
|
await callback(results, mappedQuery);
|
|
mappedQuery.offset += mappedQuery.limit;
|
|
} while (
|
|
results.length >= mappedQuery.limit &&
|
|
(mappedQuery.totalLimit ?? Infinity) > mappedQuery.offset
|
|
);
|
|
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Returns a representation of the attributes that have changed since the last save and their previous values.
|
|
*
|
|
* @returns An object with `attributes` and `previousAttributes` keys.
|
|
*/
|
|
public get changeset(): NonAttribute<{
|
|
attributes: Partial<TModelAttributes>;
|
|
previous: Partial<TModelAttributes>;
|
|
}> {
|
|
const changes = this.changed() as Array<keyof TModelAttributes> | false;
|
|
const attributes: Partial<TModelAttributes> = {};
|
|
const previousAttributes: Partial<TModelAttributes> = {};
|
|
|
|
if (!changes) {
|
|
return {
|
|
attributes,
|
|
previous: previousAttributes,
|
|
};
|
|
}
|
|
|
|
const virtualFields = (this.constructor as typeof Model).virtualFields;
|
|
const blobFields = (this.constructor as typeof Model).blobFields;
|
|
const skippedFields = getChangsetSkipped(this);
|
|
|
|
for (const change of changes) {
|
|
const previous = this.previous(change);
|
|
const current = this.getDataValue(change);
|
|
|
|
if (
|
|
virtualFields.includes(String(change)) ||
|
|
blobFields.includes(String(change)) ||
|
|
skippedFields.includes(String(change))
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
isObject(previous) &&
|
|
isObject(current) &&
|
|
!isArray(previous) &&
|
|
!isArray(current)
|
|
) {
|
|
const difference = Object.keys(previous)
|
|
.concat(Object.keys(current))
|
|
// @ts-expect-error TODO
|
|
.filter((key) => !isEqual(previous[key], current[key]));
|
|
|
|
previousAttributes[change] = pick(
|
|
previous,
|
|
difference
|
|
) as TModelAttributes[keyof TModelAttributes];
|
|
attributes[change] = pick(
|
|
current,
|
|
difference
|
|
) as TModelAttributes[keyof TModelAttributes];
|
|
} else {
|
|
previousAttributes[change] = previous;
|
|
attributes[change] = current;
|
|
}
|
|
}
|
|
|
|
return {
|
|
attributes,
|
|
previous: previousAttributes,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Cache the current changeset for later use.
|
|
*/
|
|
protected cacheChangeset() {
|
|
const previous = this.changeset;
|
|
|
|
if (
|
|
Object.keys(previous.attributes).length > 0 ||
|
|
Object.keys(previous.previous).length > 0
|
|
) {
|
|
this.previousChangeset = previous;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the virtual fields for this model.
|
|
*/
|
|
protected static get virtualFields() {
|
|
const attrs = this.rawAttributes;
|
|
return Object.keys(attrs).filter(
|
|
(attr) => attrs[attr].type instanceof DataTypes.VIRTUAL
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns the blob fields for this model.
|
|
*/
|
|
protected static get blobFields() {
|
|
const attrs = this.rawAttributes;
|
|
return Object.keys(attrs).filter(
|
|
(attr) => attrs[attr].type instanceof DataTypes.BLOB
|
|
);
|
|
}
|
|
|
|
private previousChangeset: NonAttribute<{
|
|
attributes: Partial<TModelAttributes>;
|
|
previous: Partial<TModelAttributes>;
|
|
}> | null;
|
|
}
|
|
|
|
export default Model;
|