@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 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.21.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",