@grouparoo/core
Version:
The Grouparoo Core
1,200 lines (1,072 loc) • 36.5 kB
text/typescript
import Moment from "moment";
import { config, cache, log } from "actionhero";
import { CreationAttributes } from "sequelize";
import { deepStrictEqual } from "assert";
import {
Destination,
DestinationSyncModeData,
DestinationSyncOperations,
SimpleDestinationOptions,
} from "../../models/Destination";
import { GrouparooRecord } from "../../models/GrouparooRecord";
import { Export, ExportRecordPropertiesWithType } from "../../models/Export";
import { Group } from "../../models/Group";
import { Property } from "../../models/Property";
import {
ExportedRecord,
ExportRecordsPluginMethod,
ErrorWithRecordId,
DestinationMappingOptionsResponseType,
DestinationMappingOptionsMethodResponse,
ProcessExportsForRecordIds,
ExportRecordsPluginMethodResponse,
} from "../../classes/plugin";
import { destinationTypeConversions } from "../destinationTypeConversions";
import { GroupMember } from "../../models/GroupMember";
import { ExportProcessor } from "../../models/ExportProcessor";
import { Run } from "../../models/Run";
import { MappingHelper } from "../mappingHelper";
import { RecordPropertyOps } from "./recordProperty";
import { Option } from "../../models/Option";
import { getLock } from "../locks";
import { ExportOps } from "./export";
import { PropertiesCache } from "../caches/propertiesCache";
import { DestinationsCache } from "../caches/destinationsCache";
import { AppsCache } from "../../modules/caches/appsCache";
function deepStrictEqualBoolean(a: any, b: any): boolean {
try {
deepStrictEqual(a, b);
return true;
} catch (error) {
return false;
}
}
export namespace DestinationOps {
/**
* Export all the Group Members of the Groups that this Destination is Tracking
*/
export async function exportMembers(destination: Destination) {
if (destination.collection === "none") {
// nothing to do
} else if (destination.collection === "group") {
const group = await destination.$get("group");
if (group) return group.run(destination.id);
} else if (destination.collection === "model") {
const model = await destination.$get("model");
if (model) return model.run(destination.id);
} else {
throw new Error(`cannot export members for a ${destination.collection}`);
}
}
export async function updateTracking(
destination: Destination,
collection: Destination["collection"],
collectionId?: string
) {
let oldRun: Run;
let newRun: Run;
if (
destination.collection === collection &&
destination.groupId === collectionId
) {
return { oldRun, newRun }; // no changes
}
// validations
if (collectionId && collection !== "group") {
throw new Error(
`cannot track ${collectionId} without destination collection being "group"`
);
}
if (collection === "none") {
// nothing to check
} else if (collection === "group") {
if (!collectionId) {
throw new Error(`${collectionId} is required to track a group`);
}
const group = await Group.findById(collectionId);
if (group.state === "deleted") {
throw new Error(`cannot track deleted Group "${group.name}"`);
}
if (destination.modelId !== group.modelId) {
throw new Error(
`destination ${destination.id} and group ${group.id} do not share the same modelId`
);
}
} else if (collection === "model") {
if (collectionId && collectionId !== destination.modelId) {
throw new Error(
`a destination for model ${destination.modelId} cannot track another model`
);
}
}
oldRun = await exportMembers(destination); // old collection
await destination.update({
collection,
groupId: collection !== "group" ? null : collectionId,
});
newRun = await exportMembers(destination); // new collection
return { oldRun, newRun };
}
/**
* Get a preview of a GrouparooRecord for this Destination
* It is assumed that the record provided is already in a Group tracked by this Destination
*/
export async function recordPreview(
destination: Destination,
record: GrouparooRecord,
mapping: MappingHelper.Mappings,
destinationGroupMemberships: {
[groupId: string]: string;
}
) {
const recordProperties = await record.getProperties();
const mappingKeys = Object.keys(mapping);
const mappedRecordProperties: Record<string, any> = {};
const destinationMappingOptions =
await destination.destinationMappingOptions();
for (const k of mappingKeys) {
const collection = recordProperties[mapping[k]];
if (!collection) continue; // we may have an optional property that hasn't yet been set
mappedRecordProperties[k] = collection;
let destinationType: DestinationMappingOptionsResponseType = "any";
for (const j in destinationMappingOptions.properties.required) {
const destinationProperty =
destinationMappingOptions.properties.required[j];
if (destinationProperty.key === k) {
destinationType = destinationProperty.type;
}
}
for (const j in destinationMappingOptions.properties.known) {
const destinationProperty =
destinationMappingOptions.properties.known[j];
if (destinationProperty.key === k) {
destinationType = destinationProperty.type;
}
}
mappedRecordProperties[k].values = collection.values.map((value) =>
formatOutgoingRecordProperties(value, collection.type, destinationType)
);
mappedRecordProperties[k].type = destinationType;
}
const groups = await record.$get("groups");
const mappedGroupNames = groups
.filter((group) =>
Object.keys(destinationGroupMemberships).includes(group.id)
)
.map((group) => destinationGroupMemberships[group.id])
.sort();
const apiData = await record.apiData();
return Object.assign(apiData, {
properties: mappedRecordProperties,
groupNames: mappedGroupNames,
});
}
/**
* Get the Destination Connection's supported Sync Modes
*/
export async function getSupportedSyncModes(destination: Destination) {
const { pluginConnection } = await destination.getPlugin();
return {
supportedModes: pluginConnection.syncModes || [],
defaultMode: pluginConnection.defaultSyncMode,
};
}
/**
* Get the Destination Connection Options from the plugin
*/
export async function destinationConnectionOptions(
destination: Destination,
destinationOptions: SimpleDestinationOptions = {}
) {
const { pluginConnection } = await destination.getPlugin();
const app = await destination.$get("app", {
scope: null,
include: [Option],
});
const connection = await app.getConnection();
const appOptions = await app.getOptions(true);
if (!pluginConnection.methods.destinationOptions) {
throw new Error(
`cannot return destination options for ${destination.type}`
);
}
return pluginConnection.methods.destinationOptions({
connection,
app,
appId: app.id,
appOptions,
destinationOptions,
});
}
/**
* Get the Destination Mapping Options from the Plugin
*/
export async function destinationMappingOptions(
destination: Destination,
cached = true,
saveCache = true
) {
const cacheKey = `destination:${destination.id}:mappingOptions`;
const cacheDuration = 1000 * 60 * 10; // 10 minutes
if (cached) {
try {
const {
value,
}: {
value: DestinationMappingOptionsMethodResponse;
} = await cache.load(cacheKey);
return value;
} catch (error) {
if (
error.message.toString() !== "Object not found" &&
error.message.toString() !== "Object expired"
) {
throw error;
}
}
}
const { pluginConnection } = await destination.getPlugin();
const app = await destination.$get("app", {
scope: null,
include: [Option],
});
const connection = await app.getConnection();
const appOptions = await app.getOptions(true);
const destinationOptions = await destination.getOptions(true);
if (!pluginConnection.methods.destinationMappingOptions) {
throw new Error(
`cannot return destination mapping options for ${destination.type}`
);
}
const mappingOptions =
await pluginConnection.methods.destinationMappingOptions({
connection,
app,
appId: app.id,
appOptions,
destination,
destinationId: destination.id,
destinationOptions,
});
if (saveCache) await cache.save(cacheKey, mappingOptions, cacheDuration);
return mappingOptions;
}
export async function getExportArrayProperties(destination: Destination) {
const { pluginConnection } = await destination.getPlugin();
const app = await AppsCache.findOneWithCache(destination.appId);
const connection = await app.getConnection();
const appOptions = await app.getOptions(true);
const destinationOptions = await destination.getOptions(true);
if (!pluginConnection.methods.exportArrayProperties) {
throw new Error(
`cannot determine export array properties for ${destination.type}`
);
}
return pluginConnection.methods.exportArrayProperties({
connection,
app,
appId: app.id,
appOptions,
destination,
destinationId: destination.id,
destinationOptions,
});
}
/**
* Given a Destination and GrouparooRecords (and lots of related data), create all the exports that should be sent
*/
export async function exportRecords(
destination: Destination,
records: GrouparooRecord[],
synchronous = false,
force = false,
saveExports = true,
toDelete?: boolean
) {
const bulkCreates: CreationAttributes<Export>[] = [];
if (records.length === 0) return [];
if (destination.modelId !== records[0].modelId) {
throw new Error(
`destination ${destination.id} and record ${records[0].id} do not share the same modelId`
);
}
const app = await AppsCache.findOneWithCache(destination.appId);
const appOptions = await app.getOptions();
await app.validateOptions(appOptions);
const properties = await PropertiesCache.findAllWithCache(
destination.modelId
);
const destinationGroupMemberships =
await destination.getDestinationGroupMemberships();
const mapping = await destination.getMapping();
const syncMode = await destination.getSyncMode();
const newGroupMembers = await GroupMember.findAll({
where: { recordId: records.map((r) => r.id) },
include: [Group],
});
// New and old properties and groups are in the context of this destination and what it has currently been sent
// If there is not a mostRecentExport, both old groups and old record properties are an empty collection
const mostRecentExportIds = await getMostRecentExportIds(
destination,
records.map((r) => r.id),
"complete",
"completedAt"
);
const mostRecentExports = await Export.findAll({
where: { id: mostRecentExportIds },
});
// Send only the properties from the array that should be sent to the Destination, otherwise send the first entry in the array of record properties
const exportArrayProperties = await getExportArrayProperties(destination);
for (const record of records) {
let mappedOldRecordProperties: ExportRecordPropertiesWithType = {};
let mappedNewRecordProperties: ExportRecordPropertiesWithType = {};
let oldGroupNames: string[] = [];
let newGroupNames: string[] = [];
const newRecordProperties = await record.getProperties();
const newGroups = newGroupMembers
.filter((gm) => gm.recordId === record.id)
.map((gm) => gm.group);
const mostRecentExport = mostRecentExports.find(
(e) => e.recordId === record.id
);
// Do we need to delete this record from the destination?
// If toDelete was not provided explicitly, determine what to do
if (typeof toDelete !== "boolean") {
toDelete = false;
if (destination.collection === "none") {
toDelete = true;
} else if (destination.collection === "group") {
if (!destination.groupId) {
toDelete = true;
} else if (
!newGroups.map((g) => g.id).includes(destination.groupId)
) {
toDelete = true;
}
}
}
if (mostRecentExport && mostRecentExport.toDelete !== true) {
mappedOldRecordProperties = JSON.parse(
mostRecentExport.getDataValue("newRecordProperties")
);
oldGroupNames = mostRecentExport.newGroups;
}
// We want to be able to delete records that have been removed from their source
// (new properties would be set to null, so we need the old values to reference them)
const primaryKeyRecordProperties = Object.values(
newRecordProperties
).find((p) => p.isPrimaryKey);
const forceOldPropertyValues =
toDelete &&
primaryKeyRecordProperties &&
primaryKeyRecordProperties.values[0] === null;
for (const k in mapping) {
const property = properties.find((r) => r.key === mapping[k]);
if (!property) throw new Error(`cannot find rule for ${mapping[k]}`);
const { type } = property;
if (forceOldPropertyValues) {
mappedNewRecordProperties[k] =
mappedOldRecordProperties[k] !== undefined
? mappedOldRecordProperties[k]
: { type, rawValue: null };
} else {
mappedNewRecordProperties[k] = {
type,
rawValue: newRecordProperties[mapping[k]]
? await Promise.all(
newRecordProperties[mapping[k]].values.map(async (v) => {
const response = await RecordPropertyOps.buildRawValue(
v,
type
);
return response.rawValue;
})
)
: null,
};
}
}
for (const k in mappedNewRecordProperties) {
if (
mappedNewRecordProperties[k] &&
!exportArrayProperties.includes(k) &&
!exportArrayProperties.includes("*")
) {
mappedNewRecordProperties[k].rawValue
? (mappedNewRecordProperties[k].rawValue = Array.isArray(
mappedNewRecordProperties[k].rawValue
)
? mappedNewRecordProperties[k].rawValue[0]
: mappedNewRecordProperties[k].rawValue)
: delete mappedNewRecordProperties[k];
}
}
newGroupNames = newGroups
.filter((group) =>
destinationGroupMemberships
.map((dgm) => dgm.groupId)
.includes(group.id)
)
.map(
(group) =>
destinationGroupMemberships.filter(
(dgm) => dgm.groupId === group.id
)[0].remoteKey
);
const canDelete = syncMode
? DestinationSyncModeData[syncMode].operations.delete
: true;
if (toDelete && !canDelete) {
// If sync mode does not allow deleting, don't delete and just clear groups
newGroupNames = [];
toDelete = false;
}
// determine if there are changes between this export and the previous one
let hasChanges = true;
if (
!force &&
mostRecentExport &&
deepStrictEqualBoolean(
mappedOldRecordProperties,
mappedNewRecordProperties
) &&
deepStrictEqualBoolean(oldGroupNames.sort(), newGroupNames.sort()) &&
!toDelete &&
(!mostRecentExport || !mostRecentExport.toDelete)
) {
hasChanges = false;
}
bulkCreates.push({
destinationId: destination.id,
recordId: record.id,
startedAt: synchronous ? new Date() : undefined,
oldRecordProperties: mappedOldRecordProperties,
newRecordProperties: mappedNewRecordProperties,
oldGroups: oldGroupNames.sort(),
newGroups: newGroupNames.sort(),
state: "pending",
sendAt: new Date(),
hasChanges,
toDelete,
force,
});
}
const exports = saveExports
? await Export.bulkCreate(bulkCreates)
: Export.bulkBuild(bulkCreates);
if (synchronous && saveExports) {
await destination.sendExports(exports, synchronous);
}
return exports;
}
function transformError(error: Error, recordId: string): ErrorWithRecordId {
if (typeof error === "string") {
error = new Error(error);
}
if (!error) {
error = new Error(`unknown export error with record ${recordId}`);
}
const myError: ErrorWithRecordId = <ErrorWithRecordId>error;
myError.recordId = recordId;
// also can have error.errorLevel on it already
return myError;
}
async function getBatchFunction(
destination: Destination
): Promise<ExportRecordsPluginMethod> {
const { pluginConnection } = await destination.getPlugin();
if (pluginConnection.methods.exportRecords) {
return pluginConnection.methods.exportRecords;
}
const method = pluginConnection.methods.exportRecord;
if (!method) {
throw new Error(
`destination ${destination.name} (${destination.id}) has no exportRecord or exportRecords method`
);
}
// loop through each one
const singleAsBatch: ExportRecordsPluginMethod = async function ({
connection,
app,
appId,
appOptions,
destination,
destinationId,
destinationOptions,
syncOperations,
exports: _exports,
}) {
const outErrors: ErrorWithRecordId[] = [];
let outRetryDelay: number = undefined;
let outSuccess = true;
for (const _export of _exports) {
const { recordId } = _export;
try {
let { success, retryDelay, error } = await method({
connection,
app,
appId,
appOptions,
destination,
destinationId,
destinationOptions,
syncOperations,
export: _export,
});
if (!success || error) {
outSuccess = false;
outErrors.push(transformError(error, recordId));
}
if (retryDelay) {
if (!outRetryDelay || outRetryDelay < retryDelay) {
outRetryDelay = retryDelay;
}
}
} catch (err) {
outSuccess = false;
outErrors.push(transformError(err, recordId));
}
}
return {
errors: outErrors.length === 0 ? undefined : outErrors,
retryDelay: outRetryDelay,
success: outSuccess,
};
};
return singleAsBatch;
}
async function buildExportedRecords(
destination: Destination,
_exports: Export[]
): Promise<ExportedRecord[]> {
const exportedRecords: ExportedRecord[] = [];
const destinationMappingOptions =
await destination.destinationMappingOptions();
const records = await GrouparooRecord.findAll({
where: { id: _exports.map((e) => e.recordId) },
});
for (const _export of _exports) {
const record = records.find((r) => r.id === _export.recordId);
exportedRecords.push({
record,
recordId: record?.id,
oldRecordProperties: formatRecordPropertiesForDestination(
_export,
destination,
destinationMappingOptions,
"oldRecordProperties"
),
newRecordProperties: formatRecordPropertiesForDestination(
_export,
destination,
destinationMappingOptions,
"newRecordProperties"
),
oldGroups: _export.oldGroups,
newGroups: _export.newGroups,
toDelete: _export.toDelete,
});
}
return exportedRecords;
}
export interface CombinedError extends Error {
errors?: ErrorWithRecordId[];
}
async function handleProcessExports(
destination: Destination,
givenExports: Export[],
{ processDelay, recordIds, remoteKey }: ProcessExportsForRecordIds,
exportProcessor?: ExportProcessor
) {
const _exports = givenExports.filter((e) => recordIds.includes(e.recordId));
if (_exports.length === 0) {
if (exportProcessor) await exportProcessor.complete();
return;
}
if (!exportProcessor) {
exportProcessor = await ExportProcessor.create({
destinationId: destination.id,
remoteKey,
state: "pending",
processAt: Moment().add(processDelay, "ms").toDate(),
});
await Export.update(
{ exportProcessorId: exportProcessor.id, state: "processing" },
{ where: { id: _exports.map((e) => e.id) } }
);
} else {
await exportProcessor.retry(processDelay);
}
}
function logExportError(
destination: Destination,
error: Error & { recordId?: string }
) {
const recordId = error["recordId"]; // it might have one of these
log(
`ExportError Destination: ${destination.id} - ${
destination.name
} - Record: ${recordId ?? "Unknown"}`,
"warning",
{ error }
);
}
async function updateExports(
destination: Destination,
_exports: Export[],
exportResult?: ExportRecordsPluginMethodResponse,
combinedError?: CombinedError,
synchronous = false,
exportProcessor?: ExportProcessor
) {
if (combinedError) {
logExportError(destination, combinedError);
if (combinedError.errors) {
for (const error of combinedError.errors) {
logExportError(destination, error);
}
}
}
if (exportResult?.errors) {
for (const error of exportResult?.errors) {
logExportError(destination, error);
}
}
if (!combinedError) {
const errors = exportResult?.errors;
combinedError = new Error(
`error exporting ${
errors ? errors.length : "?"
} records to destination ${destination.name} (${destination.id}): ${
errors ? errors.map((e) => e.message).join(", ") : ""
}`
);
combinedError.errors = errors || [];
}
// problem!
if (
!exportResult?.success &&
(!combinedError.errors || combinedError.errors.length === 0)
) {
// unspecified error, so don't know specific record with issue.
const retryexportIds: string[] = [];
for (const _export of _exports) {
await _export.setError(combinedError, exportResult?.retryDelay);
retryexportIds.push(_export.id);
}
if (synchronous) {
// caller wants to raise
throw combinedError;
}
return {
success: false,
error: combinedError,
retryexportIds,
retryDelay: exportResult?.retryDelay,
};
}
// they were all correct!
if (exportResult?.success) {
const processExports = exportResult?.processExports;
await ExportOps.completeBatch(
_exports.filter(
(e) =>
!processExports || !processExports.recordIds.includes(e.recordId)
)
);
if (processExports) {
await handleProcessExports(
destination,
_exports,
processExports,
exportProcessor
);
}
return {
success: true,
error: undefined,
retryexportIds: undefined,
retryDelay: undefined,
};
}
// known specific records where there were errors
const recordsWithErrors: { [id: string]: ErrorWithRecordId } = {};
for (const errorWithId of combinedError.errors) {
const recordId = errorWithId.recordId;
if (!recordId) {
throw new Error(
`Errors returned without recordId - throw if unknown (${destination.id})`
);
}
recordsWithErrors[recordId] = errorWithId;
}
const remainingRecordsWithErrors = Object.assign({}, recordsWithErrors);
const retryexportIds: string[] = [];
for (const _export of _exports.filter(
(e) => recordsWithErrors[e.recordId]
)) {
const { recordId } = _export;
const errorWithId = recordsWithErrors[recordId];
await _export.setError(errorWithId, exportResult?.retryDelay);
delete remainingRecordsWithErrors[recordId]; // used
// "info" means that it's actually ok. for example, skipped.
// we don't need to retry it.
if (_export.errorLevel != "info") {
retryexportIds.push(_export.id);
}
}
await ExportOps.completeBatch(
_exports.filter((e) => !recordsWithErrors[e.recordId])
);
const recordsNotUsed = Object.keys(remainingRecordsWithErrors);
if (recordsNotUsed.length > 0) {
throw new Error(
`Invalid ErrorWithRecordId given but not used (${
destination.id
}): ${recordsNotUsed.join(",")}`
);
}
// because of the "info" errorLevel, it's actually possible that everything is ok again
if (retryexportIds.length === 0) {
return {
success: true,
error: undefined,
retryexportIds: undefined,
retryDelay: undefined,
};
}
if (synchronous) {
// caller wants to raise
throw combinedError;
}
return {
success: false,
error: combinedError,
retryexportIds,
retryDelay: exportResult?.retryDelay,
};
}
export async function runExportProcessor(
destination: Destination,
exportProcessor: ExportProcessor
) {
const _exports = await exportProcessor.getExportsToProcess();
const { pluginConnection } = await destination.getPlugin();
const processExportedRecords =
pluginConnection.methods.processExportedRecords;
if (!processExportedRecords) {
throw new Error(
`destination ${destination.name} (${destination.id}) has no \`processExportedRecords\` method`
);
}
const syncMode = await destination.getSyncMode();
const syncOperations: DestinationSyncOperations = syncMode
? DestinationSyncModeData[syncMode].operations
: DestinationSyncModeData.sync.operations; // if destination does not support sync modes, allow all
const options = await destination.getOptions();
const app = await destination.$get("app", {
scope: null,
include: [Option],
});
const appOptions = await app.getOptions();
const connection = await app.getConnection();
// check if parallelism ok
const parallelismOk = await app.checkAndUpdateParallelism("incr");
if (!parallelismOk) {
const error = new Error(`parallelism limit reached for ${app.type}`);
const outRetryDelay = config.tasks.timeout + 1;
await exportProcessor.retry(outRetryDelay, true);
return {
success: false,
error,
retryDelay: outRetryDelay,
retryexportIds: _exports.map((e) => e.id),
};
}
let combinedError: CombinedError = null;
let exportResult: ExportRecordsPluginMethodResponse;
try {
const exportedRecords = await buildExportedRecords(destination, _exports);
exportResult = await processExportedRecords({
connection,
app,
appId: app.id,
appOptions,
destination,
destinationId: destination.id,
destinationOptions: options,
exports: exportedRecords,
remoteKey: exportProcessor.remoteKey,
syncOperations,
});
} catch (error) {
combinedError = error;
} finally {
await app.checkAndUpdateParallelism("decr");
}
if (combinedError) {
// error wasn't with a specific record, set it on the ExportProcessor
await exportProcessor.setError(combinedError);
return {
success: false,
error: combinedError,
};
}
const { success, error, retryexportIds, retryDelay } = await updateExports(
destination,
_exports,
exportResult,
null,
false,
exportProcessor
);
if (!combinedError && !exportResult.processExports) {
await exportProcessor.complete();
}
return { success, error, retryexportIds, retryDelay };
}
async function cancelOldExport(oldExport: Export) {
await oldExport.update({
state: "canceled",
sendAt: null,
errorMessage: `Replaced by more recent export`,
errorLevel: null,
completedAt: new Date(),
});
}
async function getMostRecentExportIds(
destination: Destination,
recordIds: string[],
state?: Export["state"],
sortColumn: "createdAt" | "completedAt" = "createdAt"
) {
const values: (string | string[])[] = [destination.id];
if (state) values.push(state);
values.push(recordIds);
const mostRecentExportIds = await Export.sequelize
.query(
{
query: `
SELECT
"id"
FROM (
SELECT
"id",
ROW_NUMBER() OVER (PARTITION BY "exports"."recordId" ORDER BY "exports"."${sortColumn}" DESC) AS __rownum
FROM
"exports"
WHERE
"exports"."destinationId" = ?
${state ? `AND state = ?` : ""}
AND "exports"."recordId" IN (?)) AS __ranked
WHERE
"__ranked"."__rownum" = 1;`,
values,
},
{
type: "SELECT",
model: Export,
}
)
.then((exports) => exports.map((e) => e.id));
return mostRecentExportIds;
}
export async function sendExports(
destination: Destination,
givenExports: Export[],
synchronous = false
): Promise<{
retryDelay: number;
retryexportIds: string[];
success: boolean;
error: Error;
}> {
const _exports: Export[] = []; // those exports with changes + locks acquired
const exportsWithChanges: Export[] = [];
const exportsWithoutChanges: Export[] = [];
const locks: Awaited<ReturnType<typeof getLock>>[] = [];
for (const givenExport of givenExports) {
if (givenExport.hasChanges) {
exportsWithChanges.push(givenExport);
} else {
exportsWithoutChanges.push(givenExport);
}
}
await ExportOps.completeBatch(exportsWithoutChanges);
if (exportsWithChanges.length === 0) return;
const mostRecentExportIds = await getMostRecentExportIds(
destination,
exportsWithChanges.map(({ recordId }) => recordId)
);
try {
for (const consideredExport of exportsWithChanges) {
if (!mostRecentExportIds.includes(consideredExport.id)) {
await cancelOldExport(consideredExport);
continue;
}
const lock = await getLock(
`export:${consideredExport.recordId}:${consideredExport.destinationId}`
);
const gotLock = typeof lock === "function";
if (gotLock) {
locks.push(lock);
_exports.push(consideredExport);
}
}
if (_exports.length === 0) return;
const exportRecords: ExportRecordsPluginMethod = await getBatchFunction(
destination
);
const syncMode = await destination.getSyncMode();
const syncOperations: DestinationSyncOperations = syncMode
? DestinationSyncModeData[syncMode].operations
: DestinationSyncModeData.sync.operations; // if destination does not support sync modes, allow all
const options = await destination.getOptions();
const app = await destination.$get("app", {
scope: null,
include: [Option],
});
const appOptions = await app.getOptions();
const connection = await app.getConnection();
// check if parallelism ok
const parallelismOk = await app.checkAndUpdateParallelism("incr");
if (!parallelismOk) {
const error = new Error(`parallelism limit reached for ${app.type}`);
if (synchronous) throw error;
const outRetryDelay = config.tasks.timeout + 1;
for (const _export of _exports) {
await _export.retry(outRetryDelay, true);
}
return {
success: false,
error,
retryDelay: outRetryDelay,
retryexportIds: _exports.map((e) => e.id),
};
}
let combinedError: CombinedError = null;
let exportResult: ExportRecordsPluginMethodResponse;
try {
const exportedRecords = await buildExportedRecords(
destination,
_exports
);
exportResult = await exportRecords({
connection,
app,
appId: app.id,
appOptions,
destination,
destinationId: destination.id,
destinationOptions: options,
syncOperations,
exports: exportedRecords,
});
} catch (error) {
combinedError = error;
} finally {
await app.checkAndUpdateParallelism("decr");
}
return updateExports(
destination,
_exports,
exportResult,
combinedError,
synchronous
);
} finally {
Promise.all(locks.map((releaseLock) => releaseLock()));
}
}
export async function sendExport(
destination: Destination,
_export: Export,
synchronous = false
) {
return sendExports(destination, [_export], synchronous);
}
function formatRecordPropertiesForDestination(
_export: Export,
destination: Destination,
destinationMappingOptions: DestinationMappingOptionsMethodResponse,
key: "oldRecordProperties" | "newRecordProperties"
) {
const response: { [key: string]: any } = {};
const rawProperties: Record<string, any> = JSON.parse(
//@ts-ignore
_export["dataValues"][key]
);
for (const k in rawProperties) {
const type: Property["type"] = rawProperties[k].type;
const value = _export[key][k];
let destinationType: DestinationMappingOptionsResponseType = "any";
for (const j in destinationMappingOptions.properties.required) {
const destinationProperty =
destinationMappingOptions.properties.required[j];
if (destinationProperty.key === k) {
destinationType = destinationProperty.type;
}
}
for (const j in destinationMappingOptions.properties.known) {
const destinationProperty =
destinationMappingOptions.properties.known[j];
if (destinationProperty.key === k) {
destinationType = destinationProperty.type;
}
}
if (Array.isArray(value)) {
response[k] = value.map((v) =>
formatOutgoingRecordProperties(v, type, destinationType)
);
} else {
response[k] = formatOutgoingRecordProperties(
value,
type,
destinationType
);
}
}
return response;
}
/**
* Format Grouparoo's record properties to the type the destination wants.
*/
export function formatOutgoingRecordProperties(
value: unknown,
grouparooType: Property["type"],
destinationType: DestinationMappingOptionsResponseType
) {
if (!grouparooType) return null;
if (value === null || value === undefined) return value;
const conversionBatch = destinationTypeConversions[grouparooType];
const converter: (p: unknown) => DestinationMappingOptionsResponseType =
//@ts-ignore
conversionBatch?.[destinationType] ?? null;
if (converter) {
return converter(value);
} else {
throw new Error(
`cannot export grouparoo type ${grouparooType} to destination type ${destinationType}`
);
}
}
/**
* Determine which destinations are interested in this record due to the groups they are tracking
*/
export async function relevantFor(
record: GrouparooRecord,
groups: Group[] = []
) {
const destinations = await DestinationsCache.findAllWithCache(
record.modelId,
"ready"
);
const combinedGroupIds = groups.map((g) => g.id);
const relevantDestinations = destinations.filter(
(d) =>
(d.collection === "model" && d.modelId === record.modelId) ||
(d.collection === "group" && combinedGroupIds.includes(d.groupId))
);
return relevantDestinations;
}
}