@octaviaflow/flow-rules 0.16.0 → 0.17.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.
@@ -29,7 +29,7 @@ export interface FieldMapping {
29
29
  /**
30
30
  * "Find by" (DEV-480): the mapped value names a record in the target app —
31
31
  * the write uses the id of the `object` record whose `matchField` equals it.
32
- * Root level only; on a line field it applies to each line. Odoo today.
32
+ * Root level only; on a line field it applies to each line. Odoo and QuickBooks (DEV-487).
33
33
  */
34
34
  lookup?: TransformLookup;
35
35
  }
@@ -46,10 +46,26 @@ export interface TransformLookup {
46
46
  * `lineField` become a list of line objects under `into`, in row order.
47
47
  */
48
48
  export interface TransformGroupBy {
49
- /** Source expression of the key, written like a sourceField: `{{orders[].number}}`. */
50
- key: string;
49
+ /** Source expression of the key, written like a sourceField: `{{orders[].number}}`. Optional with `linesFrom`. */
50
+ key?: string;
51
51
  /** Target field holding the list of lines: `line_items` (Shopify), `Line` (QuickBooks), `order_line` (Odoo). */
52
52
  into: string;
53
+ /**
54
+ * A list on each row that supplies its lines (DEV-484): `{{orders[].line_items}}`.
55
+ * Each element makes one line; a line field under the list
56
+ * (`{{orders[].line_items[].quantity}}`) reads the element, any other reads the row.
57
+ */
58
+ linesFrom?: string;
59
+ }
60
+ /**
61
+ * One row per element of a list on each row (DEV-484): an order becomes one
62
+ * row per line item, each carrying the order's fields.
63
+ */
64
+ export interface TransformUnwind {
65
+ /** The list: `{{orders[].line_items}}`. */
66
+ path: string;
67
+ /** Keep a row whose list is empty, as one row with no element. Default false. */
68
+ keepEmpty?: boolean;
53
69
  }
54
70
  export type TransformMode = 'visual' | 'expression';
55
71
  /**
@@ -91,8 +107,10 @@ export interface TransformConfigType {
91
107
  /** Explicit upsert/update match field (legacy externalid/pkey). Empty =
92
108
  * per-connector auto-detection (Mongo _id, SF Id, SQL primary key). */
93
109
  upsertMatchKey?: string;
94
- /** One record per key, with the `lineField` mappings collected into a list (DEV-478). */
110
+ /** One record per key, or per row with lines from a list on it — the `lineField` mappings collected into a list (DEV-478, DEV-484). */
95
111
  groupBy?: TransformGroupBy;
112
+ /** One row per element of a list on each row, before mapping (DEV-484). */
113
+ unwind?: TransformUnwind;
96
114
  /** Ordered column header names — the exact columns + header text written. */
97
115
  sheetHeaders?: string[];
98
116
  /** Create the target tab on first write if it doesn't exist (default true). */
@@ -152,6 +170,11 @@ export declare const TransformConfig: {
152
170
  required: boolean;
153
171
  description: string;
154
172
  };
173
+ unwind: {
174
+ type: string;
175
+ required: boolean;
176
+ description: string;
177
+ };
155
178
  expression: {
156
179
  type: string;
157
180
  required: boolean;
@@ -50,7 +50,7 @@ export interface ForEachConfigType {
50
50
  /** dot-path on each item; items whose key was already seen are skipped. */
51
51
  idempotencyKeyPath?: string;
52
52
  /** how per-item results are collected (default 'array'). */
53
- collect?: 'array' | 'none' | 'groupBy' | 'reduce';
53
+ collect?: 'array' | 'none' | 'groupBy' | 'reduce' | 'rows';
54
54
  /** groupBy — dot-path on each result used as the group key. */
55
55
  groupByPath?: string;
56
56
  /** reduce — built-in reduction over successful results. */
@@ -93,6 +93,12 @@ export declare const ForEachConfig: {
93
93
  type: string;
94
94
  required: boolean;
95
95
  };
96
+ collect: {
97
+ type: string;
98
+ required: boolean;
99
+ options: string[];
100
+ description: string;
101
+ };
96
102
  };
97
103
  validate: (config: Partial<ForEachConfigType>) => {
98
104
  valid: boolean;
package/dist/index.cjs CHANGED
@@ -3340,7 +3340,13 @@ var ForEachConfig = {
3340
3340
  itemVariable: { type: "string", required: true },
3341
3341
  indexVariable: { type: "string", required: false },
3342
3342
  batchSize: { type: "number", required: false },
3343
- continueOnError: { type: "boolean", required: false }
3343
+ continueOnError: { type: "boolean", required: false },
3344
+ collect: {
3345
+ type: "select",
3346
+ required: false,
3347
+ options: ["array", "none", "groupBy", "reduce", "rows"],
3348
+ description: "What the Done port carries besides the counts: array (each iteration's result in `results`, the default), none, groupBy (by groupByPath), " + "reduce (reduceOp over reducePath), or rows — also `data`: every row the body produced (each iteration's Query/Transform rows, else its result), " + "so ONE step on the Done port maps and writes them all instead of a write per iteration."
3349
+ }
3344
3350
  },
3345
3351
  validate: (config) => {
3346
3352
  const errors = [];
@@ -3725,6 +3731,34 @@ var TRANSFORM_OPERATIONS = [
3725
3731
  ];
3726
3732
  var TRANSFORM_OPERATION_IDS = TRANSFORM_OPERATIONS.map((o) => o.id);
3727
3733
  var DATABASE_CONNECTOR_IDS = new Set(["mongodb", "postgresql", "mysql", "google_sheets"]);
3734
+ function groupByErrors(config) {
3735
+ const groupBy = config.groupBy;
3736
+ if (!groupBy || !(groupBy.key || groupBy.into || groupBy.linesFrom))
3737
+ return [];
3738
+ const errors = [];
3739
+ if (!groupBy.key && !groupBy.linesFrom) {
3740
+ errors.push("One record per key: choose the key field (groupBy.key)");
3741
+ }
3742
+ if (!groupBy.into)
3743
+ errors.push("One record per key: name the field that holds the lines (groupBy.into)");
3744
+ if ((groupBy.key || groupBy.linesFrom) && groupBy.into && !(config.mappings ?? []).some((m) => m.lineField)) {
3745
+ errors.push("One record per key: mark at least one mapping as a line field (lineField: true)");
3746
+ }
3747
+ return errors;
3748
+ }
3749
+ function unwindErrors(config) {
3750
+ const unwind = config.unwind;
3751
+ if (!unwind)
3752
+ return [];
3753
+ if (!unwind.path?.trim()) {
3754
+ return unwind.keepEmpty !== undefined ? ["One row per item: choose the list (unwind.path)"] : [];
3755
+ }
3756
+ const linesFrom = config.groupBy?.linesFrom;
3757
+ if (linesFrom && linesFrom.trim() === unwind.path.trim()) {
3758
+ return ["One row per item and Lines come from name the same list — use one of them (unwind.path, groupBy.linesFrom)"];
3759
+ }
3760
+ return [];
3761
+ }
3728
3762
  var TransformConfig = {
3729
3763
  defaults: {
3730
3764
  name: "Transform",
@@ -3774,12 +3808,17 @@ var TransformConfig = {
3774
3808
  mappings: {
3775
3809
  type: "array",
3776
3810
  required: false,
3777
- 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 only for now."
3811
+ 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."
3778
3812
  },
3779
3813
  groupBy: {
3780
3814
  type: "json",
3781
3815
  required: false,
3782
- description: 'One record per key, with line items: { "key": "{{orders[].number}}", "into": "line_items" }. Rows whose key is the same become ONE record ' + "(its fields from the first row); mappings with lineField: true become a list of lines under `into` (Shopify line_items, QuickBooks Line, Odoo order_line). " + "Dotted line fields nest inside each line (SalesItemLineDetail.ItemRef.value)."
3816
+ description: "Records with line items; mappings with lineField: true become a list of lines under `into` (Shopify line_items, QuickBooks Line, Odoo order_line). " + 'Two ways, combinable: (1) the lines are flat rows — { "key": "{{orders[].number}}", "into": "order_line" }: rows with the same key become ONE record ' + '(its fields from the first row), one line per row; (2) the lines are a list INSIDE each row — { "into": "order_line", "linesFrom": "{{orders[].line_items}}" }: ' + "each row is one record and each element of the list one line; a line field under the list ({{orders[].line_items[].quantity}}) reads the element, " + "any other path reads the row. Dotted line fields nest inside each line (SalesItemLineDetail.ItemRef.value)."
3817
+ },
3818
+ unwind: {
3819
+ type: "json",
3820
+ required: false,
3821
+ description: 'One row per element of a list on each row: { "path": "{{orders[].line_items}}", "keepEmpty": false }. Every mapping then reads the element under the ' + "list path ({{orders[].line_items[].sku}}) and the row everywhere else ({{orders[].name}}) — an order becomes one row per line, for targets that take " + "flat rows (Sheets, SQL). A row whose list is empty is dropped unless keepEmpty is true."
3783
3822
  },
3784
3823
  expression: { type: "code", required: false }
3785
3824
  },
@@ -3825,16 +3864,7 @@ var TransformConfig = {
3825
3864
  });
3826
3865
  }
3827
3866
  }
3828
- const groupBy = config.groupBy;
3829
- if (groupBy && (groupBy.key || groupBy.into)) {
3830
- if (!groupBy.key)
3831
- errors.push("One record per key: choose the key field (groupBy.key)");
3832
- if (!groupBy.into)
3833
- errors.push("One record per key: name the field that holds the lines (groupBy.into)");
3834
- if (groupBy.key && groupBy.into && !(config.mappings ?? []).some((m) => m.lineField)) {
3835
- errors.push("One record per key: mark at least one mapping as a line field (lineField: true)");
3836
- }
3837
- }
3867
+ errors.push(...groupByErrors(config), ...unwindErrors(config));
3838
3868
  return { valid: errors.length === 0, errors };
3839
3869
  },
3840
3870
  merge: (userConfig) => {
package/dist/index.d.ts CHANGED
@@ -36,7 +36,7 @@ export { EmailConfig, } from "./catalog/communication/email-send/config";
36
36
  export { queryDefinition, } from "./catalog/general/query/index";
37
37
  export { isQueryableConnector, } from "./catalog/general/query/config";
38
38
  export { AggregateFunction, type BuildQueryConfig, type ColumnSchema, type ConnectorSchema, JoinType, LogicalOperator, type QueryExecutionResult, type QueryField, type QueryFilter, type QueryFilterGroup, type QueryJoin, type QuerySort, SortDirection, 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 TransformOperation, } from "./catalog/general/transform/config";
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
40
  export { AGGREGATE_FUNCTIONS, AGGREGATE_FUNCTION_DESCRIPTIONS, AggregateConfig, type AggregateConfigType, type AggregateFn, type AggregateMetric, type AggregateSchemaNode, groupKeyName, } from "./catalog/general/aggregate/config";
41
41
  export { detectRecordFields, resolveRecordSchema, type RecordSchemaLike, } from "./catalog/logic/for-each/recordSchema";
42
42
  export { STORAGE_CONNECTOR_IDS, type StorageExtraField, type StoragePathField, getStorageProvider, storageConfigKeys, } from "./catalog/storage/storageCapabilities";
package/dist/index.js CHANGED
@@ -3182,7 +3182,13 @@ var ForEachConfig = {
3182
3182
  itemVariable: { type: "string", required: true },
3183
3183
  indexVariable: { type: "string", required: false },
3184
3184
  batchSize: { type: "number", required: false },
3185
- continueOnError: { type: "boolean", required: false }
3185
+ continueOnError: { type: "boolean", required: false },
3186
+ collect: {
3187
+ type: "select",
3188
+ required: false,
3189
+ options: ["array", "none", "groupBy", "reduce", "rows"],
3190
+ description: "What the Done port carries besides the counts: array (each iteration's result in `results`, the default), none, groupBy (by groupByPath), " + "reduce (reduceOp over reducePath), or rows — also `data`: every row the body produced (each iteration's Query/Transform rows, else its result), " + "so ONE step on the Done port maps and writes them all instead of a write per iteration."
3191
+ }
3186
3192
  },
3187
3193
  validate: (config) => {
3188
3194
  const errors = [];
@@ -3567,6 +3573,34 @@ var TRANSFORM_OPERATIONS = [
3567
3573
  ];
3568
3574
  var TRANSFORM_OPERATION_IDS = TRANSFORM_OPERATIONS.map((o) => o.id);
3569
3575
  var DATABASE_CONNECTOR_IDS = new Set(["mongodb", "postgresql", "mysql", "google_sheets"]);
3576
+ function groupByErrors(config) {
3577
+ const groupBy = config.groupBy;
3578
+ if (!groupBy || !(groupBy.key || groupBy.into || groupBy.linesFrom))
3579
+ return [];
3580
+ const errors = [];
3581
+ if (!groupBy.key && !groupBy.linesFrom) {
3582
+ errors.push("One record per key: choose the key field (groupBy.key)");
3583
+ }
3584
+ if (!groupBy.into)
3585
+ errors.push("One record per key: name the field that holds the lines (groupBy.into)");
3586
+ if ((groupBy.key || groupBy.linesFrom) && groupBy.into && !(config.mappings ?? []).some((m) => m.lineField)) {
3587
+ errors.push("One record per key: mark at least one mapping as a line field (lineField: true)");
3588
+ }
3589
+ return errors;
3590
+ }
3591
+ function unwindErrors(config) {
3592
+ const unwind = config.unwind;
3593
+ if (!unwind)
3594
+ return [];
3595
+ if (!unwind.path?.trim()) {
3596
+ return unwind.keepEmpty !== undefined ? ["One row per item: choose the list (unwind.path)"] : [];
3597
+ }
3598
+ const linesFrom = config.groupBy?.linesFrom;
3599
+ if (linesFrom && linesFrom.trim() === unwind.path.trim()) {
3600
+ return ["One row per item and Lines come from name the same list — use one of them (unwind.path, groupBy.linesFrom)"];
3601
+ }
3602
+ return [];
3603
+ }
3570
3604
  var TransformConfig = {
3571
3605
  defaults: {
3572
3606
  name: "Transform",
@@ -3616,12 +3650,17 @@ var TransformConfig = {
3616
3650
  mappings: {
3617
3651
  type: "array",
3618
3652
  required: false,
3619
- 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 only for now."
3653
+ 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."
3620
3654
  },
3621
3655
  groupBy: {
3622
3656
  type: "json",
3623
3657
  required: false,
3624
- description: 'One record per key, with line items: { "key": "{{orders[].number}}", "into": "line_items" }. Rows whose key is the same become ONE record ' + "(its fields from the first row); mappings with lineField: true become a list of lines under `into` (Shopify line_items, QuickBooks Line, Odoo order_line). " + "Dotted line fields nest inside each line (SalesItemLineDetail.ItemRef.value)."
3658
+ description: "Records with line items; mappings with lineField: true become a list of lines under `into` (Shopify line_items, QuickBooks Line, Odoo order_line). " + 'Two ways, combinable: (1) the lines are flat rows — { "key": "{{orders[].number}}", "into": "order_line" }: rows with the same key become ONE record ' + '(its fields from the first row), one line per row; (2) the lines are a list INSIDE each row — { "into": "order_line", "linesFrom": "{{orders[].line_items}}" }: ' + "each row is one record and each element of the list one line; a line field under the list ({{orders[].line_items[].quantity}}) reads the element, " + "any other path reads the row. Dotted line fields nest inside each line (SalesItemLineDetail.ItemRef.value)."
3659
+ },
3660
+ unwind: {
3661
+ type: "json",
3662
+ required: false,
3663
+ description: 'One row per element of a list on each row: { "path": "{{orders[].line_items}}", "keepEmpty": false }. Every mapping then reads the element under the ' + "list path ({{orders[].line_items[].sku}}) and the row everywhere else ({{orders[].name}}) — an order becomes one row per line, for targets that take " + "flat rows (Sheets, SQL). A row whose list is empty is dropped unless keepEmpty is true."
3625
3664
  },
3626
3665
  expression: { type: "code", required: false }
3627
3666
  },
@@ -3667,16 +3706,7 @@ var TransformConfig = {
3667
3706
  });
3668
3707
  }
3669
3708
  }
3670
- const groupBy = config.groupBy;
3671
- if (groupBy && (groupBy.key || groupBy.into)) {
3672
- if (!groupBy.key)
3673
- errors.push("One record per key: choose the key field (groupBy.key)");
3674
- if (!groupBy.into)
3675
- errors.push("One record per key: name the field that holds the lines (groupBy.into)");
3676
- if (groupBy.key && groupBy.into && !(config.mappings ?? []).some((m) => m.lineField)) {
3677
- errors.push("One record per key: mark at least one mapping as a line field (lineField: true)");
3678
- }
3679
- }
3709
+ errors.push(...groupByErrors(config), ...unwindErrors(config));
3680
3710
  return { valid: errors.length === 0, errors };
3681
3711
  },
3682
3712
  merge: (userConfig) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octaviaflow/flow-rules",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "The flow action catalog and the rules engine behind Flow Doctor — 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",