@grouparoo/core
Version:
The Grouparoo Core
448 lines (381 loc) • 11.3 kB
text/typescript
import { api } from "actionhero";
import {
Table,
Column,
AllowNull,
Length,
Default,
BelongsTo,
HasMany,
ForeignKey,
BeforeValidate,
BeforeUpdate,
BeforeCreate,
BeforeSave,
AfterDestroy,
DataType,
DefaultScope,
BeforeDestroy,
AfterSave,
} from "sequelize-typescript";
import { Op } from "sequelize";
import { Source, SimpleSourceOptions } from "./Source";
import { Property } from "./Property";
import { App, SimpleAppOptions } from "./App";
import { Run } from "./Run";
import { Option } from "./Option";
import { OptionHelper } from "./../modules/optionHelper";
import { StateMachine } from "./../modules/stateMachine";
import { ScheduleOps } from "../modules/ops/schedule";
import { LockableHelper } from "../modules/lockableHelper";
import { ConfigWriter } from "../modules/configWriter";
import { CLS } from "../modules/cls";
import { APIData } from "../modules/apiData";
import { FilterHelper } from "../modules/filterHelper";
import { Filter } from "./Filter";
import { ScheduleConfigurationObject } from "../classes/codeConfig";
import { CommonModel } from "../classes/commonModel";
/**
* Metadata and methods to return the options a Schedule for this connection/app.
* Options is a method which will poll the source for available options to select (ie: names of tables or columns)
*/
export interface PluginConnectionScheduleOption {
key: string;
required: boolean;
description: string;
type: "list" | "text" | "textarea";
options: (argument: {
connection: any;
app: App;
appId: string;
appOptions: SimpleAppOptions;
source: Source;
sourceId: string;
sourceOptions: SimpleSourceOptions;
sourceMapping: SimpleSourceOptions;
properties: Property[];
}) => Promise<
{
key: string;
description?: string;
examples?: any[];
}[]
>;
}
export interface ScheduleFiltersWithKey extends FilterHelper.FiltersWithKey {}
export interface SimpleScheduleOptions extends OptionHelper.SimpleOptions {}
const STATES = ["draft", "ready"] as const;
const STATE_TRANSITIONS = [
{
from: "draft",
to: "ready",
checks: [(instance: Schedule) => instance.validateOptions()],
},
];
export class Schedule extends CommonModel<Schedule> {
idPrefix() {
return "sch";
}
sourceId: string;
name: string;
state: typeof STATES[number];
locked: string;
incremental: boolean;
recurring: boolean;
recurringFrequency: number;
confirmRecords: boolean;
refreshEnabled: boolean;
__options: Option[]; // the underscores are needed as "options" is an internal method on sequelize instances
filters: Filter[];
runs: Run[];
source: Source;
async getOptions(sourceFromEnvironment = true) {
return OptionHelper.getOptions(this, sourceFromEnvironment);
}
async setOptions(options: SimpleScheduleOptions, externallyValidate = true) {
return OptionHelper.setOptions(this, options, externallyValidate);
}
async afterSetOptions(hasChanges: boolean) {
if (!hasChanges) return;
if (this.state !== "ready") return;
await this.resetHighWatermarks();
await this.enqueueRun();
}
async validateOptions(
options?: SimpleScheduleOptions,
externallyValidate = true
) {
if (!options) options = await this.getOptions(true);
if (!externallyValidate) return;
const pluginOptions = await this.pluginOptions();
const optionsSpec: OptionHelper.OptionsSpec = pluginOptions.map((opt) => ({
...opt,
options: opt.options?.map((o) => o.key),
}));
await OptionHelper.validateOptions(this, options, optionsSpec);
}
async getPlugin() {
return OptionHelper.getPlugin(this);
}
async pluginOptions() {
return ScheduleOps.pluginOptions(this);
}
async getFilters() {
return FilterHelper.getFilters(this);
}
async setFilters(
filters: ScheduleFiltersWithKey[],
externallyValidate = true
) {
return FilterHelper.setFilters(this, filters, externallyValidate);
}
async afterSetFilters(hasChanges: boolean) {
if (!hasChanges) return;
if (this.state !== "ready") return;
await this.resetHighWatermarks();
await this.enqueueRun();
}
async shouldRun(
options: { ignoreDeltas?: boolean; runIfNotRecurring?: boolean } = {}
) {
return ScheduleOps.shouldRun(this, options);
}
async runPercentComplete(run: Run) {
return ScheduleOps.runPercentComplete(this, run);
}
async apiData() {
const options = await this.getOptions(null);
const filters = await this.getFilters();
const { pluginConnection } = await this.getPlugin();
return {
id: this.id,
name: this.name,
state: this.state,
sourceId: this.sourceId,
incremental: this.incremental,
recurring: this.recurring,
refreshEnabled: this.refreshEnabled,
locked: this.locked,
confirmRecords: this.confirmRecords,
supportIncrementalSchedule: pluginConnection.supportIncrementalSchedule,
options,
filters,
recurringFrequency: this.recurringFrequency,
createdAt: APIData.formatDate(this.createdAt),
updatedAt: APIData.formatDate(this.updatedAt),
};
}
async enqueueRun() {
const run = await Run.create({
creatorId: this.id,
creatorType: "schedule",
state: "running",
});
await CLS.enqueueTask(
"schedule:run",
{ scheduleId: this.id, runId: run.id },
"schedules"
);
return run;
}
async resetHighWatermarks() {
let runs: Run[] = await this.$get("runs", {
where: { highWaterMark: { [Op.ne]: null } },
scope: null,
limit: 1,
});
while (runs.length > 0) {
runs = await this.$get("runs", {
where: { highWaterMark: { [Op.ne]: null } },
scope: null,
limit: 100,
});
for (const run of runs) if (run.state === "running") await run.stop();
// sequelize doesn't seem to be able to set a value back to null
if (runs.length > 0) {
await api.sequelize.query(
`UPDATE "runs" SET "highWaterMark" = NULL WHERE "id" IN (${runs
.map((r) => `'${r.id}'`)
.join(", ")});`
);
}
}
}
async run(run: Run) {
return ScheduleOps.run(this, run);
}
getConfigId() {
return this.idIsDefault() ? ConfigWriter.generateId(this.name) : this.id;
}
async getConfigObject(): Promise<ScheduleConfigurationObject> {
const {
name,
recurring,
incremental,
recurringFrequency,
confirmRecords,
refreshEnabled,
} = this;
this.source = await this.$get("source");
const sourceId = this.source?.getConfigId();
if (!sourceId || !name) return;
const options = await this.getOptions(false);
const filters = await this.getFilters();
return {
class: "Schedule",
id: this.getConfigId(),
name,
sourceId,
incremental,
recurring,
recurringFrequency,
confirmRecords,
refreshEnabled,
options,
filters,
};
}
// --- Class Methods --- //
static async ensureSourceOptions(instance: Schedule) {
const source = await Source.findById(instance.sourceId);
const sourceOptions = await source.getOptions(true);
await source.validateOptions(sourceOptions);
}
static async noUpdateIfLocked(instance: Schedule) {
await LockableHelper.beforeSave(instance, ["state"]);
}
static async checkRecurringFrequency(instance: Schedule) {
// we cannot use the @Min validator as null is also allowed
if (instance.recurring) {
if (
!instance.recurringFrequency ||
instance.recurringFrequency < 1000 * 60
) {
throw new Error(
"recurring frequency is required to be one minute or greater"
);
}
}
}
static async ensureName(instance: Schedule) {
if (!instance.name) {
const source = await Source.findById(instance.sourceId);
instance.name = `${source.name} schedule`;
}
}
static async ensureSetIncremental(instance: Schedule) {
if (instance.isNewRecord && typeof instance.incremental === "undefined") {
const { pluginConnection } = await instance.getPlugin();
instance.incremental = Boolean(
pluginConnection.supportIncrementalSchedule
);
}
}
static async ensureSourceCanUseSchedule(instance: Schedule) {
const source = await Source.findById(instance.sourceId);
if (source.state !== "ready") throw new Error("source is not ready");
const scheduleAvailable = await source.scheduleAvailable();
if (!scheduleAvailable) {
throw new Error(
`source ${source.name} (${instance.sourceId}) cannot have a schedule`
);
}
}
static async ensureOnePerSource(instance: Schedule) {
const existingCount = await Schedule.scope(null).count({
where: {
sourceId: instance.sourceId,
},
});
if (existingCount > 0) {
throw new Error(`source ${instance.sourceId} already has a schedule`);
}
}
static async checkIncremental(instance: Schedule) {
const { pluginConnection } = await instance.getPlugin();
if (instance.incremental && !pluginConnection.supportIncrementalSchedule) {
throw new Error(
`${pluginConnection.name} does not support incremental schedules`
);
}
}
static async updateState(instance: Schedule) {
await StateMachine.transition(instance, STATE_TRANSITIONS);
}
static async runAfterSave(instance: Schedule) {
const shouldRun = await instance.shouldRun();
if (shouldRun) await instance.enqueueRun();
}
static async noDestroyIfLocked(instance: Schedule) {
await LockableHelper.beforeDestroy(instance);
}
static async destroyAppOptions(instance: Schedule) {
return Option.destroy({
where: { ownerId: instance.id, ownerType: "schedule" },
});
}
static async destroyFilters(instance: Property) {
await Filter.destroy({
where: { ownerId: instance.id, ownerType: "schedule" },
});
}
static async destroyRuns(instance: Schedule) {
const runs = await Run.findAll({
where: { creatorId: instance.id },
});
for (const i in runs) await runs[i].destroy();
}
}