@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 +9 -0
- package/dist/index.cjs +58 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.js +58 -3
- 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):
|
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.
|
|
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",
|