@octaviaflow/flow-rules 0.22.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):
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,
@@ -161,6 +162,7 @@ __export(exports_src, {
161
162
  storageConfigKeys: () => storageConfigKeys,
162
163
  storageOperations: () => storageOperations,
163
164
  summarizeConnectorTriggerEvents: () => summarizeConnectorTriggerEvents,
165
+ syncMatchKeyWarning: () => syncMatchKeyWarning,
164
166
  transformConnectionToEdge: () => transformConnectionToEdge,
165
167
  transformFlowToWorkflow: () => transformFlowToWorkflow,
166
168
  transformStepToNode: () => transformStepToNode,
@@ -5293,9 +5295,52 @@ function contractValidationErrors(node) {
5293
5295
  }
5294
5296
  }
5295
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
+
5296
5341
  // src/rules/workflowValidator.ts
5297
5342
  var nodeName = (n) => n.label || n.id;
5298
- function validateNode(node, allNodes, allEdges) {
5343
+ function validateNode(node, allNodes, allEdges, context = {}) {
5299
5344
  const errors = [];
5300
5345
  if (!node.id || node.id.trim().length === 0) {
5301
5346
  errors.push({
@@ -5350,6 +5395,16 @@ function validateNode(node, allNodes, allEdges) {
5350
5395
  nodeId: node.id
5351
5396
  });
5352
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
+ }
5353
5408
  const hasOutput = allEdges.some((edge) => edge.source === node.id);
5354
5409
  if (!hasOutput && node.type === "trigger") {
5355
5410
  errors.push({
@@ -5408,7 +5463,7 @@ function validateEdge(edge, allNodes, allEdges = []) {
5408
5463
  }
5409
5464
  return errors;
5410
5465
  }
5411
- function validateWorkflow(nodes, edges) {
5466
+ function validateWorkflow(nodes, edges, context = {}) {
5412
5467
  const allErrors = [];
5413
5468
  const hasTrigger = nodes.some((node) => node.type === "trigger");
5414
5469
  if (!hasTrigger) {
@@ -5429,7 +5484,7 @@ function validateWorkflow(nodes, edges) {
5429
5484
  });
5430
5485
  }
5431
5486
  nodes.forEach((node) => {
5432
- const nodeErrors = validateNode(node, nodes, edges);
5487
+ const nodeErrors = validateNode(node, nodes, edges, context);
5433
5488
  allErrors.push(...nodeErrors);
5434
5489
  });
5435
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";
package/dist/index.js CHANGED
@@ -5115,9 +5115,52 @@ function contractValidationErrors(node) {
5115
5115
  }
5116
5116
  }
5117
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
+
5118
5161
  // src/rules/workflowValidator.ts
5119
5162
  var nodeName = (n) => n.label || n.id;
5120
- function validateNode(node, allNodes, allEdges) {
5163
+ function validateNode(node, allNodes, allEdges, context = {}) {
5121
5164
  const errors = [];
5122
5165
  if (!node.id || node.id.trim().length === 0) {
5123
5166
  errors.push({
@@ -5172,6 +5215,16 @@ function validateNode(node, allNodes, allEdges) {
5172
5215
  nodeId: node.id
5173
5216
  });
5174
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
+ }
5175
5228
  const hasOutput = allEdges.some((edge) => edge.source === node.id);
5176
5229
  if (!hasOutput && node.type === "trigger") {
5177
5230
  errors.push({
@@ -5230,7 +5283,7 @@ function validateEdge(edge, allNodes, allEdges = []) {
5230
5283
  }
5231
5284
  return errors;
5232
5285
  }
5233
- function validateWorkflow(nodes, edges) {
5286
+ function validateWorkflow(nodes, edges, context = {}) {
5234
5287
  const allErrors = [];
5235
5288
  const hasTrigger = nodes.some((node) => node.type === "trigger");
5236
5289
  if (!hasTrigger) {
@@ -5251,7 +5304,7 @@ function validateWorkflow(nodes, edges) {
5251
5304
  });
5252
5305
  }
5253
5306
  nodes.forEach((node) => {
5254
- const nodeErrors = validateNode(node, nodes, edges);
5307
+ const nodeErrors = validateNode(node, nodes, edges, context);
5255
5308
  allErrors.push(...nodeErrors);
5256
5309
  });
5257
5310
  edges.forEach((edge) => {
@@ -6247,6 +6300,7 @@ export {
6247
6300
  ManualTriggerConfig,
6248
6301
  NodeStatus,
6249
6302
  ONEDRIVE_OPERATIONS,
6303
+ OWN_ID_MATCH_TARGETS,
6250
6304
  OneDriveActionConfig,
6251
6305
  PLATFORM_CONFIG_KEYS,
6252
6306
  QUERY_AGGREGATE_FUNCTIONS,
@@ -6323,6 +6377,7 @@ export {
6323
6377
  storageConfigKeys,
6324
6378
  storageOperations,
6325
6379
  summarizeConnectorTriggerEvents,
6380
+ syncMatchKeyWarning,
6326
6381
  transformConnectionToEdge,
6327
6382
  transformFlowToWorkflow,
6328
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.22.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",