@octaviaflow/flow-rules 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/catalog/general/transform/config.d.ts +24 -0
- package/dist/index.cjs +87 -5
- package/dist/index.d.ts +3 -2
- package/dist/index.js +87 -5
- package/dist/rules/syncMatchKey.d.ts +48 -0
- package/dist/rules/workflowValidator.d.ts +13 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,8 +50,17 @@ const required = action?.inputSchema.fields.filter((f) => f.validation?.required
|
|
|
50
50
|
|
|
51
51
|
// Does this flow work?
|
|
52
52
|
const { isValid, errors, warnings } = validateWorkflow(nodes, edges);
|
|
53
|
+
|
|
54
|
+
// With what the caller knows beyond the canvas (optional, 0.23.0+):
|
|
55
|
+
validateWorkflow(nodes, edges, { connectorTypes: { [connectionId]: "salesforce" } });
|
|
53
56
|
```
|
|
54
57
|
|
|
58
|
+
`connectorTypes` maps each connection id the flow uses to its connector type.
|
|
59
|
+
With it, a Transform that upserts or updates into a target whose fallback key is
|
|
60
|
+
its own record id (Salesforce, MongoDB, Shopify, Odoo, HubSpot objects other
|
|
61
|
+
than contacts and companies), with no match field and no mapping that writes
|
|
62
|
+
that id, gets a warning (DEV-646). Without it, that rule says nothing.
|
|
63
|
+
|
|
55
64
|
### The severity contract
|
|
56
65
|
|
|
57
66
|
Unchanged from rules-v2 (DEV-220, ADR-flow-pause-and-rules-v2):
|
|
@@ -67,6 +67,18 @@ export interface TransformUnwind {
|
|
|
67
67
|
/** Keep a row whose list is empty, as one row with no element. Default false. */
|
|
68
68
|
keepEmpty?: boolean;
|
|
69
69
|
}
|
|
70
|
+
/** How rows are written — the Transform panel's "Advanced options" (DEV-267/268). */
|
|
71
|
+
export interface TransformWriteSettings {
|
|
72
|
+
/** Rows per write call. Empty = the connector's own default. */
|
|
73
|
+
batchSize?: number;
|
|
74
|
+
/** `continue` (default): failed rows are reported and the rest are written. `fail`: the step stops at the first failed row. */
|
|
75
|
+
onError?: 'continue' | 'fail';
|
|
76
|
+
/** Map and validate, write nothing. */
|
|
77
|
+
dryRun?: boolean;
|
|
78
|
+
/** Put the failed rows on the step's output. */
|
|
79
|
+
exposeFailedRows?: boolean;
|
|
80
|
+
}
|
|
81
|
+
export declare const TRANSFORM_ON_ERROR: readonly NonNullable<TransformWriteSettings['onError']>[];
|
|
70
82
|
export type TransformMode = 'visual' | 'expression';
|
|
71
83
|
/**
|
|
72
84
|
* The authoring modes. `visual` is the Field Mapper and the only mode the panel
|
|
@@ -111,6 +123,8 @@ export interface TransformConfigType {
|
|
|
111
123
|
groupBy?: TransformGroupBy;
|
|
112
124
|
/** One row per element of a list on each row, before mapping (DEV-484). */
|
|
113
125
|
unwind?: TransformUnwind;
|
|
126
|
+
/** How rows are written (the panel's "Advanced options", DEV-267/268). */
|
|
127
|
+
writeSettings?: TransformWriteSettings;
|
|
114
128
|
/** Ordered column header names — the exact columns + header text written. */
|
|
115
129
|
sheetHeaders?: string[];
|
|
116
130
|
/** Create the target tab on first write if it doesn't exist (default true). */
|
|
@@ -139,6 +153,16 @@ export declare const TransformConfig: {
|
|
|
139
153
|
options: readonly TransformOperation[];
|
|
140
154
|
description: string;
|
|
141
155
|
};
|
|
156
|
+
upsertMatchKey: {
|
|
157
|
+
type: string;
|
|
158
|
+
required: boolean;
|
|
159
|
+
description: string;
|
|
160
|
+
};
|
|
161
|
+
writeSettings: {
|
|
162
|
+
type: string;
|
|
163
|
+
required: boolean;
|
|
164
|
+
description: string;
|
|
165
|
+
};
|
|
142
166
|
connectorId: {
|
|
143
167
|
type: string;
|
|
144
168
|
required: boolean;
|
package/dist/index.cjs
CHANGED
|
@@ -85,6 +85,7 @@ __export(exports_src, {
|
|
|
85
85
|
ManualTriggerConfig: () => ManualTriggerConfig,
|
|
86
86
|
NodeStatus: () => NodeStatus,
|
|
87
87
|
ONEDRIVE_OPERATIONS: () => ONEDRIVE_OPERATIONS,
|
|
88
|
+
OWN_ID_MATCH_TARGETS: () => OWN_ID_MATCH_TARGETS,
|
|
88
89
|
OneDriveActionConfig: () => OneDriveActionConfig,
|
|
89
90
|
PLATFORM_CONFIG_KEYS: () => PLATFORM_CONFIG_KEYS,
|
|
90
91
|
QUERY_AGGREGATE_FUNCTIONS: () => QUERY_AGGREGATE_FUNCTIONS,
|
|
@@ -107,6 +108,7 @@ __export(exports_src, {
|
|
|
107
108
|
SortDirection: () => SortDirection,
|
|
108
109
|
StorageActionConfig: () => StorageActionConfig,
|
|
109
110
|
TRANSFORM_MODES: () => TRANSFORM_MODES,
|
|
111
|
+
TRANSFORM_ON_ERROR: () => TRANSFORM_ON_ERROR,
|
|
110
112
|
TRANSFORM_OPERATIONS: () => TRANSFORM_OPERATIONS,
|
|
111
113
|
TransformConfig: () => TransformConfig,
|
|
112
114
|
USER_FUNCTION_PREFIX: () => USER_FUNCTION_PREFIX,
|
|
@@ -160,6 +162,7 @@ __export(exports_src, {
|
|
|
160
162
|
storageConfigKeys: () => storageConfigKeys,
|
|
161
163
|
storageOperations: () => storageOperations,
|
|
162
164
|
summarizeConnectorTriggerEvents: () => summarizeConnectorTriggerEvents,
|
|
165
|
+
syncMatchKeyWarning: () => syncMatchKeyWarning,
|
|
163
166
|
transformConnectionToEdge: () => transformConnectionToEdge,
|
|
164
167
|
transformFlowToWorkflow: () => transformFlowToWorkflow,
|
|
165
168
|
transformStepToNode: () => transformStepToNode,
|
|
@@ -3387,6 +3390,7 @@ var WhatsappSendConfig = {
|
|
|
3387
3390
|
};
|
|
3388
3391
|
|
|
3389
3392
|
// src/catalog/general/transform/config.ts
|
|
3393
|
+
var TRANSFORM_ON_ERROR = ["continue", "fail"];
|
|
3390
3394
|
var TRANSFORM_MODES = ["visual", "expression"];
|
|
3391
3395
|
var TRANSFORM_OPERATIONS = [
|
|
3392
3396
|
{ id: "upsert", label: "Upsert" },
|
|
@@ -3411,6 +3415,21 @@ function groupByErrors(config) {
|
|
|
3411
3415
|
}
|
|
3412
3416
|
return errors;
|
|
3413
3417
|
}
|
|
3418
|
+
function writeSettingsErrors(config) {
|
|
3419
|
+
const ws = config.writeSettings;
|
|
3420
|
+
if (ws === undefined || ws === null)
|
|
3421
|
+
return [];
|
|
3422
|
+
if (typeof ws !== "object" || Array.isArray(ws))
|
|
3423
|
+
return ["Write settings must be an object (writeSettings)"];
|
|
3424
|
+
const errors = [];
|
|
3425
|
+
if (ws.onError !== undefined && !isOneOf(TRANSFORM_ON_ERROR, ws.onError)) {
|
|
3426
|
+
errors.push(`Write settings: onError must be one of: ${TRANSFORM_ON_ERROR.join(", ")} (got "${String(ws.onError)}")`);
|
|
3427
|
+
}
|
|
3428
|
+
if (ws.batchSize !== undefined && !(Number.isInteger(ws.batchSize) && ws.batchSize > 0)) {
|
|
3429
|
+
errors.push(`Write settings: batchSize must be a whole number above 0 (got "${String(ws.batchSize)}")`);
|
|
3430
|
+
}
|
|
3431
|
+
return errors;
|
|
3432
|
+
}
|
|
3414
3433
|
function unwindErrors(config) {
|
|
3415
3434
|
const unwind = config.unwind;
|
|
3416
3435
|
if (!unwind)
|
|
@@ -3452,6 +3471,16 @@ var TransformConfig = {
|
|
|
3452
3471
|
options: TRANSFORM_OPERATION_IDS,
|
|
3453
3472
|
description: "What to do with each mapped record at the target: upsert, insert, update or delete"
|
|
3454
3473
|
},
|
|
3474
|
+
upsertMatchKey: {
|
|
3475
|
+
type: "text",
|
|
3476
|
+
required: false,
|
|
3477
|
+
description: "For upsert and update: the TARGET field that says which existing record a row is (DEV-646). A row whose value matches a record updates it; " + "no match creates one (upsert). Empty = the connector decides: Salesforce Id, MongoDB _id, the SQL primary key. " + "A sync that may run more than once must upsert on a key that is stable and unique for the SOURCE record, or every run creates the records again. " + "Salesforce: the key must be an External ID field on the object (describe_connector_object) — mapping a source id into a normal field is not enough, and " + 'without one the upsert fails with "External ID is not found in record". If the object has no External ID field for this source, say so and ask the person: ' + "they create one in Salesforce (for example a HubSpot Contact Id text field marked External ID and Unique), or choose insert knowing re-runs duplicate."
|
|
3478
|
+
},
|
|
3479
|
+
writeSettings: {
|
|
3480
|
+
type: "json",
|
|
3481
|
+
required: false,
|
|
3482
|
+
description: 'How rows are written: { "batchSize"?: number, "onError"?: "continue" | "fail", "dryRun"?: boolean, "exposeFailedRows"?: boolean }. ' + 'onError "continue" (the default) writes every row it can and reports the failures (the run ends as partial success); "fail" stops the step at the first failed row. ' + "dryRun maps and validates without writing."
|
|
3483
|
+
},
|
|
3455
3484
|
connectorId: { type: "connection", required: true },
|
|
3456
3485
|
database: {
|
|
3457
3486
|
type: "text",
|
|
@@ -3473,7 +3502,7 @@ var TransformConfig = {
|
|
|
3473
3502
|
mappings: {
|
|
3474
3503
|
type: "array",
|
|
3475
3504
|
required: false,
|
|
3476
|
-
description: "Field mappings: [{ id, sourceField, targetField, targetType, transform? }]. sourceField is a token like {{orders[].email}}; " + "an index picks one list element ({{products[].variants[0].price}}). With groupBy, set lineField: true on the mappings that make each line. " + 'When the target field holds a record id but you have a reference (an email, SHOP-123), add lookup: { "object": "product.product", "matchField": "default_code" } — ' + "the write uses the id of the one record whose matchField equals the value; no match, or more than one, fails that row. Odoo and QuickBooks Online (e.g. CustomerRef.value → Customer by DisplayName; " + "Line.SalesItemLineDetail.ItemRef.value → Item by Name). A QuickBooks transaction upsert matches on DocNumber unless upsertMatchKey says otherwise."
|
|
3505
|
+
description: "Field mappings: [{ id, sourceField, targetField, targetType, transform? }]. sourceField is a token like {{orders[].email}}; " + "targetField is the target app's own field name, normally one describe_connector_object lists; a name it does not list is written as given, so the field must exist in the target app (a custom field the person created). " + "A field the target requires (Salesforce Account Name) must be mapped, and a source value that can be empty needs a fallback or the row fails. " + "an index picks one list element ({{products[].variants[0].price}}). With groupBy, set lineField: true on the mappings that make each line. " + 'When the target field holds a record id but you have a reference (an email, SHOP-123), add lookup: { "object": "product.product", "matchField": "default_code" } — ' + "the write uses the id of the one record whose matchField equals the value; no match, or more than one, fails that row. Odoo and QuickBooks Online (e.g. CustomerRef.value → Customer by DisplayName; " + "Line.SalesItemLineDetail.ItemRef.value → Item by Name). A QuickBooks transaction upsert matches on DocNumber unless upsertMatchKey says otherwise."
|
|
3477
3506
|
},
|
|
3478
3507
|
groupBy: {
|
|
3479
3508
|
type: "json",
|
|
@@ -3529,7 +3558,7 @@ var TransformConfig = {
|
|
|
3529
3558
|
});
|
|
3530
3559
|
}
|
|
3531
3560
|
}
|
|
3532
|
-
errors.push(...groupByErrors(config), ...unwindErrors(config));
|
|
3561
|
+
errors.push(...groupByErrors(config), ...unwindErrors(config), ...writeSettingsErrors(config));
|
|
3533
3562
|
return { valid: errors.length === 0, errors };
|
|
3534
3563
|
},
|
|
3535
3564
|
merge: (userConfig) => {
|
|
@@ -5266,9 +5295,52 @@ function contractValidationErrors(node) {
|
|
|
5266
5295
|
}
|
|
5267
5296
|
}
|
|
5268
5297
|
|
|
5298
|
+
// src/rules/syncMatchKey.ts
|
|
5299
|
+
var OWN_ID_MATCH_TARGETS = {
|
|
5300
|
+
salesforce: {
|
|
5301
|
+
app: "Salesforce",
|
|
5302
|
+
idFields: ["Id"],
|
|
5303
|
+
hint: "an External ID field on the object that holds the source record's id"
|
|
5304
|
+
},
|
|
5305
|
+
mongodb: { app: "MongoDB", idFields: ["_id"], hint: "a field that is unique for each source record" },
|
|
5306
|
+
shopify: { app: "Shopify", idFields: ["id"], hint: "a field Shopify can look up, such as email, sku or handle" },
|
|
5307
|
+
odoo: { app: "Odoo", idFields: ["id"], hint: "a field that is unique for each source record" },
|
|
5308
|
+
hubspot: {
|
|
5309
|
+
app: "HubSpot",
|
|
5310
|
+
idFields: ["id", "hs_object_id"],
|
|
5311
|
+
naturalKeyObjects: new Set(["contacts", "companies"]),
|
|
5312
|
+
hint: "a property that is unique for each source record"
|
|
5313
|
+
}
|
|
5314
|
+
};
|
|
5315
|
+
var MATCHING_OPERATIONS = new Set(["upsert", "update"]);
|
|
5316
|
+
var text = (v) => typeof v === "string" ? v.trim() : "";
|
|
5317
|
+
function syncMatchKeyWarning(node, connectorTypes) {
|
|
5318
|
+
if (node.actionDefinitionId !== "transform" || !connectorTypes)
|
|
5319
|
+
return null;
|
|
5320
|
+
const config = node.config ?? {};
|
|
5321
|
+
const operation = text(config.operation);
|
|
5322
|
+
if (!MATCHING_OPERATIONS.has(operation))
|
|
5323
|
+
return null;
|
|
5324
|
+
if (text(config.upsertMatchKey))
|
|
5325
|
+
return null;
|
|
5326
|
+
if (text(config.mode) === "expression")
|
|
5327
|
+
return null;
|
|
5328
|
+
const connectionId = text(config.connectorId);
|
|
5329
|
+
const target = connectionId ? OWN_ID_MATCH_TARGETS[connectorTypes[connectionId] ?? ""] : undefined;
|
|
5330
|
+
if (!target)
|
|
5331
|
+
return null;
|
|
5332
|
+
if (target.naturalKeyObjects?.has(text(config.objectName).toLowerCase()))
|
|
5333
|
+
return null;
|
|
5334
|
+
const mappings = Array.isArray(config.mappings) ? config.mappings : [];
|
|
5335
|
+
if (mappings.some((m) => target.idFields.includes(text(m?.targetField))))
|
|
5336
|
+
return null;
|
|
5337
|
+
const consequence = operation === "upsert" ? "the rows from another app don't carry it, so every run fails or, run as insert, creates the records again" : "the rows from another app don't carry it, so nothing is updated";
|
|
5338
|
+
return `No match field: this ${operation} into ${target.app} matches on ${target.app}'s own record id, and ${consequence}. ` + `Set a match field (${target.hint}).`;
|
|
5339
|
+
}
|
|
5340
|
+
|
|
5269
5341
|
// src/rules/workflowValidator.ts
|
|
5270
5342
|
var nodeName = (n) => n.label || n.id;
|
|
5271
|
-
function validateNode(node, allNodes, allEdges) {
|
|
5343
|
+
function validateNode(node, allNodes, allEdges, context = {}) {
|
|
5272
5344
|
const errors = [];
|
|
5273
5345
|
if (!node.id || node.id.trim().length === 0) {
|
|
5274
5346
|
errors.push({
|
|
@@ -5323,6 +5395,16 @@ function validateNode(node, allNodes, allEdges) {
|
|
|
5323
5395
|
nodeId: node.id
|
|
5324
5396
|
});
|
|
5325
5397
|
}
|
|
5398
|
+
const syncKey = syncMatchKeyWarning(node, context.connectorTypes);
|
|
5399
|
+
if (syncKey) {
|
|
5400
|
+
errors.push({
|
|
5401
|
+
id: `node-${node.id}-sync-key`,
|
|
5402
|
+
type: "node",
|
|
5403
|
+
severity: "warning",
|
|
5404
|
+
message: syncKey,
|
|
5405
|
+
nodeId: node.id
|
|
5406
|
+
});
|
|
5407
|
+
}
|
|
5326
5408
|
const hasOutput = allEdges.some((edge) => edge.source === node.id);
|
|
5327
5409
|
if (!hasOutput && node.type === "trigger") {
|
|
5328
5410
|
errors.push({
|
|
@@ -5381,7 +5463,7 @@ function validateEdge(edge, allNodes, allEdges = []) {
|
|
|
5381
5463
|
}
|
|
5382
5464
|
return errors;
|
|
5383
5465
|
}
|
|
5384
|
-
function validateWorkflow(nodes, edges) {
|
|
5466
|
+
function validateWorkflow(nodes, edges, context = {}) {
|
|
5385
5467
|
const allErrors = [];
|
|
5386
5468
|
const hasTrigger = nodes.some((node) => node.type === "trigger");
|
|
5387
5469
|
if (!hasTrigger) {
|
|
@@ -5402,7 +5484,7 @@ function validateWorkflow(nodes, edges) {
|
|
|
5402
5484
|
});
|
|
5403
5485
|
}
|
|
5404
5486
|
nodes.forEach((node) => {
|
|
5405
|
-
const nodeErrors = validateNode(node, nodes, edges);
|
|
5487
|
+
const nodeErrors = validateNode(node, nodes, edges, context);
|
|
5406
5488
|
allErrors.push(...nodeErrors);
|
|
5407
5489
|
});
|
|
5408
5490
|
edges.forEach((edge) => {
|
package/dist/index.d.ts
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
*/
|
|
21
21
|
export * from "./catalog/types";
|
|
22
22
|
export { actionsRegistry, getAllActions, getActionById, getActionsByCategory, } from "./catalog/index";
|
|
23
|
-
export { validateNode, validateEdge, validateWorkflow, getNodeValidationStatus, getEdgeValidationStatus, type ValidationError, type ValidationResult, } from "./rules/workflowValidator";
|
|
23
|
+
export { validateNode, validateEdge, validateWorkflow, getNodeValidationStatus, getEdgeValidationStatus, type ValidationError, type ValidationResult, type ValidationContext, } from "./rules/workflowValidator";
|
|
24
|
+
export { OWN_ID_MATCH_TARGETS, syncMatchKeyWarning } from "./rules/syncMatchKey";
|
|
24
25
|
export { getNodeConfigStatus, missingContractRequired, contractValidationErrors, type NodeConfigStatus, type NodeConfigStatusResult, } from "./rules/nodeConfigStatus";
|
|
25
26
|
export { generateWorkflowId, generateStepId, generateEdgeId, generateUUIDv4, } from "./ids";
|
|
26
27
|
export { CONDITION_OPERATORS, ConditionConfig, type ConditionFilter, type ConditionOperator, } from "./catalog/logic/condition/config";
|
|
@@ -36,7 +37,7 @@ export { EmailConfig, } from "./catalog/communication/email-send/config";
|
|
|
36
37
|
export { queryDefinition, } from "./catalog/general/query/index";
|
|
37
38
|
export { isQueryableConnector, queryFieldsSatisfied, queryTablesSatisfied, validateBuildQueryConfig, validateVisualConfig, QUERY_AUTHORED_SQL_MESSAGE, QUERY_VISUAL_ONLY_MESSAGE, } from "./catalog/general/query/config";
|
|
38
39
|
export { type BuildQueryConfig, type ColumnSchema, type CompiledQuery, type Condition, type ConditionGroup, type ConditionRow, type ConnectorSchema, isConditionGroup, MAX_CONDITION_DEPTH, QUERY_AGGREGATE_FUNCTIONS, QUERY_COMPARISON_OPERATORS, QUERY_CONDITION_OPERATORS, QUERY_MODES, QUERY_RELATIVE_DATE_OPERATORS, QUERY_TYPES, type QueryAggregate, type QueryAggregateFn, type QueryComparisonOperator, type QueryConditionOperator, type QueryExecutionResult, type QueryJoin, type QueryJoinOperator, type QuerySort, RELATIVE_DATE_DIRECTIONS, RELATIVE_DATE_MAX_AMOUNT, RELATIVE_DATE_UNITS, type RelativeDate, type RelativeDateDirection, type RelativeDateUnit, SortDirection, type VisualConfigV2, type VisualQueryConfig, } from "./catalog/general/query/types";
|
|
39
|
-
export { DATABASE_CONNECTOR_IDS, type FieldMapping, TRANSFORM_MODES, TRANSFORM_OPERATIONS, TransformConfig, type TransformConfigType, type TransformGroupBy, type TransformLookup, type TransformUnwind, type TransformOperation, } from "./catalog/general/transform/config";
|
|
40
|
+
export { DATABASE_CONNECTOR_IDS, type FieldMapping, TRANSFORM_MODES, TRANSFORM_ON_ERROR, TRANSFORM_OPERATIONS, TransformConfig, type TransformConfigType, type TransformGroupBy, type TransformLookup, type TransformUnwind, type TransformOperation, type TransformWriteSettings, } from "./catalog/general/transform/config";
|
|
40
41
|
export { AGGREGATE_FUNCTIONS, AGGREGATE_FUNCTION_DESCRIPTIONS, AggregateConfig, type AggregateConfigType, type AggregateFn, type AggregateMetric, type AggregateSchemaNode, groupKeyName, } from "./catalog/general/aggregate/config";
|
|
41
42
|
export { detectRecordFields, resolveRecordSchema, type RecordSchemaLike, } from "./catalog/logic/for-each/recordSchema";
|
|
42
43
|
export { STORAGE_CONNECTOR_IDS, type StorageExtraField, type StoragePathField, getStorageProvider, storageConfigKeys, } from "./catalog/storage/storageCapabilities";
|
package/dist/index.js
CHANGED
|
@@ -3210,6 +3210,7 @@ var WhatsappSendConfig = {
|
|
|
3210
3210
|
};
|
|
3211
3211
|
|
|
3212
3212
|
// src/catalog/general/transform/config.ts
|
|
3213
|
+
var TRANSFORM_ON_ERROR = ["continue", "fail"];
|
|
3213
3214
|
var TRANSFORM_MODES = ["visual", "expression"];
|
|
3214
3215
|
var TRANSFORM_OPERATIONS = [
|
|
3215
3216
|
{ id: "upsert", label: "Upsert" },
|
|
@@ -3234,6 +3235,21 @@ function groupByErrors(config) {
|
|
|
3234
3235
|
}
|
|
3235
3236
|
return errors;
|
|
3236
3237
|
}
|
|
3238
|
+
function writeSettingsErrors(config) {
|
|
3239
|
+
const ws = config.writeSettings;
|
|
3240
|
+
if (ws === undefined || ws === null)
|
|
3241
|
+
return [];
|
|
3242
|
+
if (typeof ws !== "object" || Array.isArray(ws))
|
|
3243
|
+
return ["Write settings must be an object (writeSettings)"];
|
|
3244
|
+
const errors = [];
|
|
3245
|
+
if (ws.onError !== undefined && !isOneOf(TRANSFORM_ON_ERROR, ws.onError)) {
|
|
3246
|
+
errors.push(`Write settings: onError must be one of: ${TRANSFORM_ON_ERROR.join(", ")} (got "${String(ws.onError)}")`);
|
|
3247
|
+
}
|
|
3248
|
+
if (ws.batchSize !== undefined && !(Number.isInteger(ws.batchSize) && ws.batchSize > 0)) {
|
|
3249
|
+
errors.push(`Write settings: batchSize must be a whole number above 0 (got "${String(ws.batchSize)}")`);
|
|
3250
|
+
}
|
|
3251
|
+
return errors;
|
|
3252
|
+
}
|
|
3237
3253
|
function unwindErrors(config) {
|
|
3238
3254
|
const unwind = config.unwind;
|
|
3239
3255
|
if (!unwind)
|
|
@@ -3275,6 +3291,16 @@ var TransformConfig = {
|
|
|
3275
3291
|
options: TRANSFORM_OPERATION_IDS,
|
|
3276
3292
|
description: "What to do with each mapped record at the target: upsert, insert, update or delete"
|
|
3277
3293
|
},
|
|
3294
|
+
upsertMatchKey: {
|
|
3295
|
+
type: "text",
|
|
3296
|
+
required: false,
|
|
3297
|
+
description: "For upsert and update: the TARGET field that says which existing record a row is (DEV-646). A row whose value matches a record updates it; " + "no match creates one (upsert). Empty = the connector decides: Salesforce Id, MongoDB _id, the SQL primary key. " + "A sync that may run more than once must upsert on a key that is stable and unique for the SOURCE record, or every run creates the records again. " + "Salesforce: the key must be an External ID field on the object (describe_connector_object) — mapping a source id into a normal field is not enough, and " + 'without one the upsert fails with "External ID is not found in record". If the object has no External ID field for this source, say so and ask the person: ' + "they create one in Salesforce (for example a HubSpot Contact Id text field marked External ID and Unique), or choose insert knowing re-runs duplicate."
|
|
3298
|
+
},
|
|
3299
|
+
writeSettings: {
|
|
3300
|
+
type: "json",
|
|
3301
|
+
required: false,
|
|
3302
|
+
description: 'How rows are written: { "batchSize"?: number, "onError"?: "continue" | "fail", "dryRun"?: boolean, "exposeFailedRows"?: boolean }. ' + 'onError "continue" (the default) writes every row it can and reports the failures (the run ends as partial success); "fail" stops the step at the first failed row. ' + "dryRun maps and validates without writing."
|
|
3303
|
+
},
|
|
3278
3304
|
connectorId: { type: "connection", required: true },
|
|
3279
3305
|
database: {
|
|
3280
3306
|
type: "text",
|
|
@@ -3296,7 +3322,7 @@ var TransformConfig = {
|
|
|
3296
3322
|
mappings: {
|
|
3297
3323
|
type: "array",
|
|
3298
3324
|
required: false,
|
|
3299
|
-
description: "Field mappings: [{ id, sourceField, targetField, targetType, transform? }]. sourceField is a token like {{orders[].email}}; " + "an index picks one list element ({{products[].variants[0].price}}). With groupBy, set lineField: true on the mappings that make each line. " + 'When the target field holds a record id but you have a reference (an email, SHOP-123), add lookup: { "object": "product.product", "matchField": "default_code" } — ' + "the write uses the id of the one record whose matchField equals the value; no match, or more than one, fails that row. Odoo and QuickBooks Online (e.g. CustomerRef.value → Customer by DisplayName; " + "Line.SalesItemLineDetail.ItemRef.value → Item by Name). A QuickBooks transaction upsert matches on DocNumber unless upsertMatchKey says otherwise."
|
|
3325
|
+
description: "Field mappings: [{ id, sourceField, targetField, targetType, transform? }]. sourceField is a token like {{orders[].email}}; " + "targetField is the target app's own field name, normally one describe_connector_object lists; a name it does not list is written as given, so the field must exist in the target app (a custom field the person created). " + "A field the target requires (Salesforce Account Name) must be mapped, and a source value that can be empty needs a fallback or the row fails. " + "an index picks one list element ({{products[].variants[0].price}}). With groupBy, set lineField: true on the mappings that make each line. " + 'When the target field holds a record id but you have a reference (an email, SHOP-123), add lookup: { "object": "product.product", "matchField": "default_code" } — ' + "the write uses the id of the one record whose matchField equals the value; no match, or more than one, fails that row. Odoo and QuickBooks Online (e.g. CustomerRef.value → Customer by DisplayName; " + "Line.SalesItemLineDetail.ItemRef.value → Item by Name). A QuickBooks transaction upsert matches on DocNumber unless upsertMatchKey says otherwise."
|
|
3300
3326
|
},
|
|
3301
3327
|
groupBy: {
|
|
3302
3328
|
type: "json",
|
|
@@ -3352,7 +3378,7 @@ var TransformConfig = {
|
|
|
3352
3378
|
});
|
|
3353
3379
|
}
|
|
3354
3380
|
}
|
|
3355
|
-
errors.push(...groupByErrors(config), ...unwindErrors(config));
|
|
3381
|
+
errors.push(...groupByErrors(config), ...unwindErrors(config), ...writeSettingsErrors(config));
|
|
3356
3382
|
return { valid: errors.length === 0, errors };
|
|
3357
3383
|
},
|
|
3358
3384
|
merge: (userConfig) => {
|
|
@@ -5089,9 +5115,52 @@ function contractValidationErrors(node) {
|
|
|
5089
5115
|
}
|
|
5090
5116
|
}
|
|
5091
5117
|
|
|
5118
|
+
// src/rules/syncMatchKey.ts
|
|
5119
|
+
var OWN_ID_MATCH_TARGETS = {
|
|
5120
|
+
salesforce: {
|
|
5121
|
+
app: "Salesforce",
|
|
5122
|
+
idFields: ["Id"],
|
|
5123
|
+
hint: "an External ID field on the object that holds the source record's id"
|
|
5124
|
+
},
|
|
5125
|
+
mongodb: { app: "MongoDB", idFields: ["_id"], hint: "a field that is unique for each source record" },
|
|
5126
|
+
shopify: { app: "Shopify", idFields: ["id"], hint: "a field Shopify can look up, such as email, sku or handle" },
|
|
5127
|
+
odoo: { app: "Odoo", idFields: ["id"], hint: "a field that is unique for each source record" },
|
|
5128
|
+
hubspot: {
|
|
5129
|
+
app: "HubSpot",
|
|
5130
|
+
idFields: ["id", "hs_object_id"],
|
|
5131
|
+
naturalKeyObjects: new Set(["contacts", "companies"]),
|
|
5132
|
+
hint: "a property that is unique for each source record"
|
|
5133
|
+
}
|
|
5134
|
+
};
|
|
5135
|
+
var MATCHING_OPERATIONS = new Set(["upsert", "update"]);
|
|
5136
|
+
var text = (v) => typeof v === "string" ? v.trim() : "";
|
|
5137
|
+
function syncMatchKeyWarning(node, connectorTypes) {
|
|
5138
|
+
if (node.actionDefinitionId !== "transform" || !connectorTypes)
|
|
5139
|
+
return null;
|
|
5140
|
+
const config = node.config ?? {};
|
|
5141
|
+
const operation = text(config.operation);
|
|
5142
|
+
if (!MATCHING_OPERATIONS.has(operation))
|
|
5143
|
+
return null;
|
|
5144
|
+
if (text(config.upsertMatchKey))
|
|
5145
|
+
return null;
|
|
5146
|
+
if (text(config.mode) === "expression")
|
|
5147
|
+
return null;
|
|
5148
|
+
const connectionId = text(config.connectorId);
|
|
5149
|
+
const target = connectionId ? OWN_ID_MATCH_TARGETS[connectorTypes[connectionId] ?? ""] : undefined;
|
|
5150
|
+
if (!target)
|
|
5151
|
+
return null;
|
|
5152
|
+
if (target.naturalKeyObjects?.has(text(config.objectName).toLowerCase()))
|
|
5153
|
+
return null;
|
|
5154
|
+
const mappings = Array.isArray(config.mappings) ? config.mappings : [];
|
|
5155
|
+
if (mappings.some((m) => target.idFields.includes(text(m?.targetField))))
|
|
5156
|
+
return null;
|
|
5157
|
+
const consequence = operation === "upsert" ? "the rows from another app don't carry it, so every run fails or, run as insert, creates the records again" : "the rows from another app don't carry it, so nothing is updated";
|
|
5158
|
+
return `No match field: this ${operation} into ${target.app} matches on ${target.app}'s own record id, and ${consequence}. ` + `Set a match field (${target.hint}).`;
|
|
5159
|
+
}
|
|
5160
|
+
|
|
5092
5161
|
// src/rules/workflowValidator.ts
|
|
5093
5162
|
var nodeName = (n) => n.label || n.id;
|
|
5094
|
-
function validateNode(node, allNodes, allEdges) {
|
|
5163
|
+
function validateNode(node, allNodes, allEdges, context = {}) {
|
|
5095
5164
|
const errors = [];
|
|
5096
5165
|
if (!node.id || node.id.trim().length === 0) {
|
|
5097
5166
|
errors.push({
|
|
@@ -5146,6 +5215,16 @@ function validateNode(node, allNodes, allEdges) {
|
|
|
5146
5215
|
nodeId: node.id
|
|
5147
5216
|
});
|
|
5148
5217
|
}
|
|
5218
|
+
const syncKey = syncMatchKeyWarning(node, context.connectorTypes);
|
|
5219
|
+
if (syncKey) {
|
|
5220
|
+
errors.push({
|
|
5221
|
+
id: `node-${node.id}-sync-key`,
|
|
5222
|
+
type: "node",
|
|
5223
|
+
severity: "warning",
|
|
5224
|
+
message: syncKey,
|
|
5225
|
+
nodeId: node.id
|
|
5226
|
+
});
|
|
5227
|
+
}
|
|
5149
5228
|
const hasOutput = allEdges.some((edge) => edge.source === node.id);
|
|
5150
5229
|
if (!hasOutput && node.type === "trigger") {
|
|
5151
5230
|
errors.push({
|
|
@@ -5204,7 +5283,7 @@ function validateEdge(edge, allNodes, allEdges = []) {
|
|
|
5204
5283
|
}
|
|
5205
5284
|
return errors;
|
|
5206
5285
|
}
|
|
5207
|
-
function validateWorkflow(nodes, edges) {
|
|
5286
|
+
function validateWorkflow(nodes, edges, context = {}) {
|
|
5208
5287
|
const allErrors = [];
|
|
5209
5288
|
const hasTrigger = nodes.some((node) => node.type === "trigger");
|
|
5210
5289
|
if (!hasTrigger) {
|
|
@@ -5225,7 +5304,7 @@ function validateWorkflow(nodes, edges) {
|
|
|
5225
5304
|
});
|
|
5226
5305
|
}
|
|
5227
5306
|
nodes.forEach((node) => {
|
|
5228
|
-
const nodeErrors = validateNode(node, nodes, edges);
|
|
5307
|
+
const nodeErrors = validateNode(node, nodes, edges, context);
|
|
5229
5308
|
allErrors.push(...nodeErrors);
|
|
5230
5309
|
});
|
|
5231
5310
|
edges.forEach((edge) => {
|
|
@@ -6221,6 +6300,7 @@ export {
|
|
|
6221
6300
|
ManualTriggerConfig,
|
|
6222
6301
|
NodeStatus,
|
|
6223
6302
|
ONEDRIVE_OPERATIONS,
|
|
6303
|
+
OWN_ID_MATCH_TARGETS,
|
|
6224
6304
|
OneDriveActionConfig,
|
|
6225
6305
|
PLATFORM_CONFIG_KEYS,
|
|
6226
6306
|
QUERY_AGGREGATE_FUNCTIONS,
|
|
@@ -6243,6 +6323,7 @@ export {
|
|
|
6243
6323
|
SortDirection,
|
|
6244
6324
|
StorageActionConfig,
|
|
6245
6325
|
TRANSFORM_MODES,
|
|
6326
|
+
TRANSFORM_ON_ERROR,
|
|
6246
6327
|
TRANSFORM_OPERATIONS,
|
|
6247
6328
|
TransformConfig,
|
|
6248
6329
|
USER_FUNCTION_PREFIX,
|
|
@@ -6296,6 +6377,7 @@ export {
|
|
|
6296
6377
|
storageConfigKeys,
|
|
6297
6378
|
storageOperations,
|
|
6298
6379
|
summarizeConnectorTriggerEvents,
|
|
6380
|
+
syncMatchKeyWarning,
|
|
6299
6381
|
transformConnectionToEdge,
|
|
6300
6382
|
transformFlowToWorkflow,
|
|
6301
6383
|
transformStepToNode,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An upsert or update with nothing to match on (DEV-646).
|
|
3
|
+
*
|
|
4
|
+
* Prod, 2026-11-09: a HubSpot → Salesforce "sync" built over the MCP set no
|
|
5
|
+
* match field. The upsert failed ("External ID is not found in record"); run
|
|
6
|
+
* as insert instead, every run created all 140 Accounts again.
|
|
7
|
+
*
|
|
8
|
+
* With no `upsertMatchKey`, the Engine's writer falls back to a key of its
|
|
9
|
+
* own. For some targets that key is the target record's OWN id, which rows
|
|
10
|
+
* coming from another app never carry unless a mapping writes it. Those are
|
|
11
|
+
* the targets this rule warns about, mirroring the Engine:
|
|
12
|
+
*
|
|
13
|
+
* salesforce `Id` writeToSalesforce: matchKey || externalIdField || 'Id'
|
|
14
|
+
* mongodb `_id` writeToMongoDB: matchKey || '_id'
|
|
15
|
+
* shopify `id` isShopifyIdKey: no key = PUT by id
|
|
16
|
+
* odoo `id` writeToOdoo: matchKey || 'id'
|
|
17
|
+
* hubspot own id writeToHubSpot: matchKey || email (contacts) / domain (companies) || record id
|
|
18
|
+
*
|
|
19
|
+
* Silent, because the fallback is a natural key or can't be known here:
|
|
20
|
+
* QuickBooks (DocNumber / DisplayName), HubSpot contacts and companies, the
|
|
21
|
+
* SQL targets and Snowflake (the primary key), and the OData targets (SAP,
|
|
22
|
+
* Business Central, D365 F&O), whose entity key comes from live metadata.
|
|
23
|
+
*
|
|
24
|
+
* The rule needs to know which app a connection is. A Transform's
|
|
25
|
+
* `connectorId` is a connection (instance) id, so the caller passes
|
|
26
|
+
* `connectorTypes` (connection id → connector type). Without it, or for a
|
|
27
|
+
* connection it doesn't list, the rule says nothing.
|
|
28
|
+
*/
|
|
29
|
+
import type { WorkflowNode } from '../catalog/workflow';
|
|
30
|
+
interface OwnIdTarget {
|
|
31
|
+
/** How the app is named in the message. */
|
|
32
|
+
readonly app: string;
|
|
33
|
+
/** Target field names that ARE the record's own id: a mapping to one supplies the match. */
|
|
34
|
+
readonly idFields: readonly string[];
|
|
35
|
+
/** Objects whose writer falls back to a natural key instead (lower case). */
|
|
36
|
+
readonly naturalKeyObjects?: ReadonlySet<string>;
|
|
37
|
+
/** What to use as the match field, said in the app's terms. */
|
|
38
|
+
readonly hint: string;
|
|
39
|
+
}
|
|
40
|
+
/** Connector types whose upsert/update, with no match field, matches on the target record's own id. */
|
|
41
|
+
export declare const OWN_ID_MATCH_TARGETS: Readonly<Record<string, OwnIdTarget>>;
|
|
42
|
+
/**
|
|
43
|
+
* The warning for a Transform that upserts or updates into a target whose
|
|
44
|
+
* fallback is its own record id, with no match field set and no mapping that
|
|
45
|
+
* writes that id. `null` when there is nothing to say.
|
|
46
|
+
*/
|
|
47
|
+
export declare function syncMatchKeyWarning(node: WorkflowNode, connectorTypes: Readonly<Record<string, string>> | undefined): string | null;
|
|
48
|
+
export {};
|
|
@@ -16,6 +16,17 @@
|
|
|
16
16
|
* ("won't run" orphans, multi-flow canvas, duplicate edges).
|
|
17
17
|
*/
|
|
18
18
|
import { WorkflowNode, WorkflowEdge } from '../catalog/workflow';
|
|
19
|
+
/**
|
|
20
|
+
* What the caller knows beyond the canvas. Every field is optional: a rule
|
|
21
|
+
* that needs one says nothing without it.
|
|
22
|
+
*/
|
|
23
|
+
export interface ValidationContext {
|
|
24
|
+
/**
|
|
25
|
+
* Connection (instance) id → connector type (`salesforce`, `mongodb`, …),
|
|
26
|
+
* for the connections the flow's steps use (DEV-646).
|
|
27
|
+
*/
|
|
28
|
+
connectorTypes?: Readonly<Record<string, string>>;
|
|
29
|
+
}
|
|
19
30
|
export interface ValidationError {
|
|
20
31
|
id: string;
|
|
21
32
|
type: 'node' | 'edge' | 'workflow';
|
|
@@ -32,7 +43,7 @@ export interface ValidationResult {
|
|
|
32
43
|
/**
|
|
33
44
|
* Validate a single node.
|
|
34
45
|
*/
|
|
35
|
-
export declare function validateNode(node: WorkflowNode, allNodes: WorkflowNode[], allEdges: WorkflowEdge[]): ValidationError[];
|
|
46
|
+
export declare function validateNode(node: WorkflowNode, allNodes: WorkflowNode[], allEdges: WorkflowEdge[], context?: ValidationContext): ValidationError[];
|
|
36
47
|
/**
|
|
37
48
|
* Validate a single edge. `allEdges` powers real duplicate detection —
|
|
38
49
|
* only the SECOND-and-later instance of a duplicate is flagged, so one
|
|
@@ -42,7 +53,7 @@ export declare function validateEdge(edge: WorkflowEdge, allNodes: WorkflowNode[
|
|
|
42
53
|
/**
|
|
43
54
|
* Validate entire workflow.
|
|
44
55
|
*/
|
|
45
|
-
export declare function validateWorkflow(nodes: WorkflowNode[], edges: WorkflowEdge[]): ValidationResult;
|
|
56
|
+
export declare function validateWorkflow(nodes: WorkflowNode[], edges: WorkflowEdge[], context?: ValidationContext): ValidationResult;
|
|
46
57
|
/**
|
|
47
58
|
* Get validation status for a specific node
|
|
48
59
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octaviaflow/flow-rules",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "The flow action catalog and the rules engine behind Flow Doctor \u2014 one definition of what a step accepts and what makes a flow valid, shared by the editor and the server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|