@grouparoo/core
Version:
The Grouparoo Core
442 lines (388 loc) • 12.4 kB
text/typescript
import {
Table,
Column,
AllowNull,
Is,
BelongsTo,
BeforeSave,
ForeignKey,
DataType,
Default,
AfterUpdate,
} from "sequelize-typescript";
import path from "path";
import { appendFile } from "fs-extra";
import { api, config, log } from "actionhero";
import { Destination } from "./Destination";
import { GrouparooRecord } from "./GrouparooRecord";
import { plugin } from "../modules/plugin";
import Moment from "moment";
import { QueryTypes } from "sequelize";
import { ExportOps } from "../modules/ops/export";
import { APIData } from "../modules/apiData";
import { StateMachine } from "../modules/stateMachine";
import { ExportProcessor } from "./ExportProcessor";
import { Errors } from "../modules/errors";
import { PropertyTypes } from "./Property";
import { CommonModel } from "../classes/commonModel";
import { getParentPath } from "../modules/pluginDetails";
/**
* The GrouparooRecord Properties in their normal data types (string, boolean, date, etc)
*/
export interface ExportRecordProperties {
[key: string]: any | any[];
}
/**
* The GrouparooRecord Properties as stringified rawValues + types
*/
export interface ExportRecordPropertiesWithType {
[key: string]: {
type: typeof PropertyTypes[number];
rawValue: string | string[];
};
}
export const ExportStates = [
"draft", // not ready to send, needs manual review
"pending", // ready to send
"processing", // have been sent, waiting on the destination's OK
"canceled", // manually canceled
"failed", // something went wrong and we won't try again
"complete", // OK!
] as const;
const STATE_TRANSITIONS: StateMachine.StateTransition[] = [
{ from: "draft", to: "pending", checks: [] },
{ from: "draft", to: "canceled", checks: [] },
{ from: "pending", to: "processing", checks: [] },
{ from: "pending", to: "canceled", checks: [] },
{ from: "pending", to: "failed", checks: [] },
{ from: "pending", to: "complete", checks: [] },
{ from: "processing", to: "canceled", checks: [] },
{ from: "processing", to: "failed", checks: [] },
{ from: "processing", to: "complete", checks: [] },
{ from: "processing", to: "pending", checks: [] },
];
export class Export extends CommonModel<Export> {
idPrefix() {
return "exp";
}
destinationId: string;
recordId: string;
exportProcessorId: string;
state: typeof ExportStates[number];
force: boolean;
startedAt: Date;
sendAt: Date;
retryCount: number;
completedAt: Date;
errorMessage: string;
errorLevel: Errors.ErrorLevel;
get oldRecordProperties(): ExportRecordProperties {
return ExportOps.deserializeExportRecordProperties(
//@ts-ignore
this.getDataValue("oldRecordProperties")
);
}
set oldRecordProperties(value: ExportRecordProperties) {
//@ts-ignore
this.setDataValue("oldRecordProperties", JSON.stringify(value));
}
get newRecordProperties(): ExportRecordProperties {
return ExportOps.deserializeExportRecordProperties(
//@ts-ignore
this.getDataValue("newRecordProperties")
);
}
set newRecordProperties(value: ExportRecordProperties) {
//@ts-ignore
this.setDataValue("newRecordProperties", JSON.stringify(value));
}
get oldGroups(): string[] {
//@ts-ignore
return JSON.parse(this.getDataValue("oldGroups") || "[]");
}
set oldGroups(value: string[]) {
//@ts-ignore
this.setDataValue("oldGroups", JSON.stringify(value));
}
get newGroups(): string[] {
//@ts-ignore
return JSON.parse(this.getDataValue("newGroups") || "[]");
}
set newGroups(value: string[]) {
//@ts-ignore
this.setDataValue("newGroups", JSON.stringify(value));
}
toDelete: boolean;
hasChanges: boolean;
destination: Destination;
record: GrouparooRecord;
async setError(
error: Error & { errorLevel?: Export["errorLevel"] },
retryDelay?: number
) {
const maxExportAttempts = parseInt(
(await plugin.readSetting("core", "exports-max-retries-count")).value
);
this.errorMessage = error.message || error.toString();
if (error["errorLevel"]) this.errorLevel = error["errorLevel"];
if (!retryDelay) this.retryCount++;
if (this.retryCount >= maxExportAttempts) {
this.state = "failed";
this.sendAt = null;
} else if (this.errorLevel === "info") {
this.state = "failed";
} else {
this.state = "pending";
this.exportProcessorId = null;
this.sendAt = Moment()
.add(retryDelay ?? config.tasks.timeout, "ms")
.toDate();
this.startedAt = null;
}
return this.save();
}
async retry(
retryDelay: number = config.tasks.timeout,
skipCount: boolean = false
) {
const maxExportAttempts = parseInt(
(await plugin.readSetting("core", "exports-max-retries-count")).value
);
if (!skipCount) this.retryCount++;
if (this.retryCount >= maxExportAttempts) {
this.state = "failed";
this.sendAt = null;
} else {
this.sendAt = Moment().add(retryDelay, "ms").toDate();
this.startedAt = null;
}
return this.save();
}
async apiData(includeDestination = true) {
const destination =
this.destination ?? (await this.$get("destination", { scope: null }));
const record: GrouparooRecord = this.record ?? (await this.$get("record"));
return {
id: this.id,
destination:
includeDestination && destination
? {
id: destination.id,
state: destination.state,
name: destination.name,
groupId: destination.groupId,
modelId: destination.modelId,
}
: undefined,
destinationName: destination ? destination.name : null,
recordId: this.recordId,
modelId: record?.modelId,
exportProcessorId: this.exportProcessorId,
state: this.state,
force: this.force,
createdAt: APIData.formatDate(this.createdAt),
sendAt: APIData.formatDate(this.sendAt),
startedAt: APIData.formatDate(this.startedAt),
completedAt: APIData.formatDate(this.completedAt),
retryCount: this.retryCount,
oldRecordProperties: this.oldRecordProperties,
newRecordProperties: this.newRecordProperties,
oldGroups: this.oldGroups,
newGroups: this.newGroups,
toDelete: this.toDelete,
hasChanges: this.hasChanges,
errorMessage: this.errorMessage,
errorLevel: this.errorLevel,
};
}
// --- Class Methods --- //
static defaultState = "pending";
static async updateState(instance: Export) {
await StateMachine.transition(instance, STATE_TRANSITIONS);
}
static ensureErrorLevel(instance: Export) {
if (instance.errorMessage && !instance.errorLevel) {
instance.errorLevel = "error";
}
}
static async logExport(instance: Export) {
if (!process.env.GROUPAROO_EXPORT_LOG) return;
if (
instance.changed("state") &&
["canceled", "failed", "complete"].includes(instance.state)
) {
const exportData = {
...(await instance.apiData(false)),
timestamp: APIData.formatDate(new Date()),
};
const message = JSON.stringify(exportData);
if (process.env.GROUPAROO_EXPORT_LOG === "stdout") {
log(`[ export ] ${message}`);
} else {
const logPath = path.isAbsolute(process.env.GROUPAROO_EXPORT_LOG)
? process.env.GROUPAROO_EXPORT_LOG
: path.join(getParentPath(), process.env.GROUPAROO_EXPORT_LOG);
await appendFile(logPath, `${message}\n`);
}
}
}
static retryFailed(
startDate: Date,
endDate: Date,
destination?: Destination,
saveExports = true
) {
return ExportOps.retryFailedExports(
startDate,
endDate,
destination,
saveExports
);
}
static retryById(exportId: string) {
return ExportOps.retryExportById(exportId);
}
static async sweep(limit: number) {
const days = 90; // keep all exports for at least 90 days
const whereDate = Moment()
.subtract(days, "days")
.format("YYYY-MM-DD HH:mm:ss");
// for SQLite secondary changes queries
let responseCountWithCompleteExport: number;
let responseCountWithNoRecord: number;
let responseCountWithNoDestination: number;
// 1. Delete Complete Exports for the GrouparooRecord older than the newest complete Export for this Record+Destination
const rowsWithCompleteExport: { id: string }[] = await api.sequelize.query(
`
DELETE FROM exports
WHERE id IN (
SELECT id FROM exports
WHERE "state" IN ('complete', 'failed', 'canceled')
AND "createdAt" < '${whereDate}'
AND "createdAt" < (
SELECT MAX("createdAt")
FROM exports e2
WHERE
state = 'complete'
AND e2."recordId" = exports."recordId"
AND e2."destinationId" = exports."destinationId"
)
LIMIT ${limit}
)
${config.sequelize.dialect === "postgres" ? "RETURNING id" : ""}
;`,
{ type: QueryTypes.SELECT }
);
if (config.sequelize.dialect === "sqlite") {
const changesRows = await api.sequelize.query(
"SELECT changes() as count;",
{ type: QueryTypes.SELECT }
);
responseCountWithCompleteExport = changesRows[0]["count"];
}
// 2. Delete exports older than the limit which no longer have a Record
const rowsWithNoRecord: { id: string }[] = await api.sequelize.query(
`
DELETE FROM exports
WHERE id IN (
SELECT id FROM exports
WHERE "createdAt" < '${whereDate}'
AND (
SELECT id
FROM records
WHERE "records"."id" = "exports"."recordId"
) IS NULL
LIMIT ${limit}
)
${config.sequelize.dialect === "postgres" ? "RETURNING id" : ""}
;`,
{ type: QueryTypes.SELECT }
);
if (config.sequelize.dialect === "sqlite") {
const changesRows = await api.sequelize.query(
"SELECT changes() as count;",
{ type: QueryTypes.SELECT }
);
responseCountWithNoRecord = changesRows[0]["count"];
}
// 3. Delete exports older than the limit which no longer have a Destination
const rowsWithNoDestination: { id: string }[] = await api.sequelize.query(
`
DELETE FROM exports
WHERE id IN (
SELECT id FROM exports
WHERE "createdAt" < '${whereDate}'
AND (
SELECT id
FROM destinations
WHERE "destinations"."id" = "exports"."destinationId"
) IS NULL
LIMIT ${limit}
)
${config.sequelize.dialect === "postgres" ? "RETURNING id" : ""}
;`,
{ type: QueryTypes.SELECT }
);
if (config.sequelize.dialect === "sqlite") {
const changesRows = await api.sequelize.query(
"SELECT changes() as count;",
{ type: QueryTypes.SELECT }
);
responseCountWithNoDestination = changesRows[0]["count"];
}
return {
count:
config.sequelize.dialect === "postgres"
? rowsWithCompleteExport.length +
rowsWithNoRecord.length +
rowsWithNoDestination.length
: responseCountWithCompleteExport +
responseCountWithNoRecord +
responseCountWithNoDestination,
days,
};
}
}