@salesforce/afv-skills 1.60.0 → 1.62.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.
Files changed (45) hide show
  1. package/package.json +3 -3
  2. package/skills/agentforce-architecture-analyze/README.md +2 -1
  3. package/skills/agentforce-d360-analyze/README.md +1 -1
  4. package/skills/agentforce-generate/README.md +1 -0
  5. package/skills/crm-analytics-wave-generate/SKILL.md +212 -0
  6. package/skills/crm-analytics-wave-generate/assets/dashboard-template.json +66 -0
  7. package/skills/crm-analytics-wave-generate/assets/wapp-meta-template.xml +10 -0
  8. package/skills/crm-analytics-wave-generate/assets/wdash-meta-template.xml +6 -0
  9. package/skills/crm-analytics-wave-generate/assets/wdpr-meta-template.xml +11 -0
  10. package/skills/crm-analytics-wave-generate/examples/industry-binding-dashboard.wdash +67 -0
  11. package/skills/crm-analytics-wave-generate/examples/opportunity-revenue-recipe.wdpr +132 -0
  12. package/skills/crm-analytics-wave-generate/examples/pipeline-by-industry-quarter.wdpr +236 -0
  13. package/skills/crm-analytics-wave-generate/examples/wdpr-skeleton.wdpr +62 -0
  14. package/skills/crm-analytics-wave-generate/references/dashboard-authoring.md +157 -0
  15. package/skills/crm-analytics-wave-generate/references/dataset-hygiene.md +27 -0
  16. package/skills/crm-analytics-wave-generate/references/external-data-upload.md +61 -0
  17. package/skills/crm-analytics-wave-generate/references/method-decision-tree.md +12 -0
  18. package/skills/crm-analytics-wave-generate/references/r3-node-schema.md +194 -0
  19. package/skills/crm-analytics-wave-generate/references/scripts-reference.md +17 -0
  20. package/skills/crm-analytics-wave-generate/references/ui-section-template.md +68 -0
  21. package/skills/crm-analytics-wave-generate/scripts/check_connector_error.sh +53 -0
  22. package/skills/crm-analytics-wave-generate/scripts/create_app.js +59 -0
  23. package/skills/crm-analytics-wave-generate/scripts/deploy_and_run_recipe.js +122 -0
  24. package/skills/crm-analytics-wave-generate/scripts/fetch_dataset_xmd.js +59 -0
  25. package/skills/crm-analytics-wave-generate/scripts/find_unused_dataset_fields.js +96 -0
  26. package/skills/crm-analytics-wave-generate/scripts/gen_synthetic_dataset.js +137 -0
  27. package/skills/crm-analytics-wave-generate/scripts/patch_t_oppo_view.js +99 -0
  28. package/skills/crm-analytics-wave-generate/scripts/probe_recipe.js +47 -0
  29. package/skills/crm-analytics-wave-generate/scripts/upload_dataset.js +119 -0
  30. package/skills/crm-analytics-wave-generate/scripts/verify_dashboard_steps.js +99 -0
  31. package/skills/crm-analytics-wave-generate/scripts/verify_synthetic_dataset.js +86 -0
  32. package/skills/service-itsm-agentic-setup-incident-management/SKILL.md +59 -20
  33. package/skills/service-itsm-agentic-setup-incident-management/examples/output-templates.md +5 -1
  34. package/skills/service-itsm-agentic-setup-incident-management/references/incident-persona-psg.md +2 -2
  35. package/skills/service-itsm-agentic-setup-incident-management/references/incident-preferences.md +2 -2
  36. package/skills/service-itsm-agentic-setup-incident-management/references/major-incident-management.md +5 -5
  37. package/skills/service-itsm-agentic-setup-incident-management/references/service-mgmt-privilege.md +5 -5
  38. package/skills/service-itsm-agentic-setup-incident-sla-configure/SKILL.md +67 -100
  39. package/skills/service-itsm-agentic-setup-incident-sla-configure/assets/attach-milestone-action.json +2 -2
  40. package/skills/service-itsm-agentic-setup-incident-sla-configure/assets/attach-milestone.json +2 -2
  41. package/skills/service-itsm-agentic-setup-incident-sla-configure/examples/milestone-patterns.md +23 -22
  42. package/skills/service-itsm-agentic-setup-incident-sla-configure/examples/output-templates.md +3 -3
  43. package/skills/service-itsm-agentic-setup-incident-sla-configure/references/mcp-invocation.md +182 -58
  44. package/skills/service-itsm-agentic-setup-incident-sla-configure/references/workflow.md +157 -88
  45. package/skills/service-de-channel-consent-configure/SKILL.md +0 -230
@@ -0,0 +1,194 @@
1
+ # R3 Node Schema
2
+
3
+ Verified node shapes for Spring '26 / API v66 orgs.
4
+
5
+ ## load
6
+
7
+ `mode` valid values: `SYNCED` (default — replicates data before processing), `DIRECT` (queries live, no replication lag but higher API cost), `AUTO` (platform chooses).
8
+
9
+ ```json
10
+ {
11
+ "action": "load",
12
+ "sources": [],
13
+ "parameters": {
14
+ "dataset": {
15
+ "type": "connectedDataset",
16
+ "label": "Opportunity",
17
+ "connectionName": "SFDC_LOCAL",
18
+ "sourceObjectName": "Opportunity"
19
+ },
20
+ "fields": ["Id", "AccountId", "StageName", "CloseDate", "Amount"],
21
+ "mode": "SYNCED",
22
+ "preserveCurrencyFields": [],
23
+ "sampleDetails": { "type": "TopN", "sortBy": [] }
24
+ }
25
+ }
26
+ ```
27
+
28
+ ## filter
29
+ ```json
30
+ {
31
+ "action": "filter",
32
+ "sources": ["LOAD_OPPORTUNITY"],
33
+ "parameters": {
34
+ "filterExpressions": [
35
+ { "field": "StageName", "operator": "EQUAL", "operands": ["Closed Won"], "type": "TEXT" }
36
+ ]
37
+ }
38
+ }
39
+ ```
40
+
41
+ ## join
42
+ ```json
43
+ {
44
+ "action": "join",
45
+ "sources": ["FILTER_CLOSED_WON", "LOAD_ACCOUNT"],
46
+ "parameters": {
47
+ "joinType": "LOOKUP",
48
+ "leftKeys": ["AccountId"],
49
+ "rightKeys": ["Id"],
50
+ "rightQualifier": "Account"
51
+ }
52
+ }
53
+ ```
54
+ Do NOT pass `rightSelectedFields` — it is rejected. After a LOOKUP join, right-side fields are addressable as `<rightQualifier>.<FieldName>` (e.g. `Account.Name`).
55
+
56
+ **Join keys must be the same type.** `leftKeys` and `rightKeys` are paired by position. Joining an ID to an ID (`Opportunity.AccountId` → `Account.Id`) works. Joining an ID to a TEXT Name, or a NUMBER Amount to a TEXT code, fails at run time with a type-mismatch on the join keys. Do not stringify IDs to "make the types match" — pick the real lookup field.
57
+
58
+ ## formula
59
+ ```json
60
+ {
61
+ "action": "formula",
62
+ "sources": ["JOIN_ACCOUNT"],
63
+ "parameters": {
64
+ "expressionType": "SQL",
65
+ "fields": [
66
+ {
67
+ "name": "TotalAmount",
68
+ "label": "Total Amount",
69
+ "type": "NUMBER",
70
+ "formulaExpression": "Amount"
71
+ }
72
+ ]
73
+ }
74
+ }
75
+ ```
76
+
77
+ ### Critical formula rules
78
+
79
+ - **`expressionType` must be `"SQL"`** — `"FORMULA"` silently drops the `type` field, defaulting output to TEXT.
80
+ - **`type` must be `"NUMBER"`** for numeric output — `"NUMERIC"`, `"DECIMAL"`, `"INTEGER"` etc. normalize to TEXT at write time, causing runtime: *"formula generates decimal(35,17) but output type is TEXT"*.
81
+ - Valid `type` values: `"NUMBER"`, `"TEXT"`, `"DATE"`, `"DATETIME"`.
82
+ - Do NOT include `precision` or `scale` — they trigger *"Unrecognized field 'scale'"*.
83
+ - **Numeric outputs become measures, not dimensions.** SAQL `group q by 'CloseYear'` fails with errorCode 119 if CloseYear is NUMBER. Use `date_format` with `type: "TEXT"` for groupable date dimensions.
84
+ - **One field per formula node — hard UI constraint.** REST API accepts multiple fields and the recipe compiles/runs fine, but the Recipe Designer breaks with *"Can't Load the Recipe — A node can only define one field."* Always chain separate single-field formula nodes:
85
+ ```json
86
+ "COMPUTE_YEAR": { "action": "formula", "sources": ["JOIN_ACCOUNT"], "parameters": { "expressionType": "SQL", "fields": [{ "name": "CloseYear", "type": "TEXT", "formulaExpression": "date_format(CloseDate, 'yyyy')" }] } },
87
+ "COMPUTE_MONTH": { "action": "formula", "sources": ["COMPUTE_YEAR"], "parameters": { "expressionType": "SQL", "fields": [{ "name": "CloseMonth", "type": "TEXT", "formulaExpression": "date_format(CloseDate, 'MM/yyyy')" }] } }
88
+ ```
89
+ - **`date_format` patterns**: `'yyyy'` → `"2032"` (TEXT, groupable) ✓ | `'MM/yyyy'` → `"03/2032"` (TEXT, groupable) ✓ | `'MMM yyyy'` → silently produces numeric month integer ✗
90
+ - `lpad()`, `string()`, and bare `concat()` with string-converted date parts are NOT available in Wave recipe SQL — use `date_format`.
91
+ - **`coalesce()` is not reliable in recipe SQL.** Null/blank TEXT dimensions use a `case` expression, one field per node:
92
+ ```sql
93
+ case when (Account.Industry is null or Account.Industry = '') then 'Unknown' else Account.Industry end
94
+ ```
95
+ - **Conditional buckets** also use `case` (not `IFF` / `IF`). Example deal-size TEXT dimension:
96
+ ```sql
97
+ case when Amount >= 100000 then 'Large' when Amount >= 50000 then 'Medium' else 'Small' end
98
+ ```
99
+ - **Quarter as TEXT**: there is no proven `'QQ'` / `'Q'` `date_format` pattern. Derive it from the month string:
100
+ ```sql
101
+ case when date_format(CloseDate, 'MM') <= '03' then 'Q1' when date_format(CloseDate, 'MM') <= '06' then 'Q2' when date_format(CloseDate, 'MM') <= '09' then 'Q3' else 'Q4' end
102
+ ```
103
+ - **Changing formula field `type` requires DELETE + re-POST** — `PATCH ?format=R3` may not update the runtime compiled artifact; multipart PATCH destroys compiled state entirely.
104
+
105
+ ## extractGrains (EXTRACT0)
106
+ ```json
107
+ {
108
+ "action": "extractGrains",
109
+ "sources": ["COMPUTE_CLOSE_MONTH"],
110
+ "parameters": { "grainExtractions": [] }
111
+ }
112
+ ```
113
+ Always insert EXTRACT0 between the last formula node and aggregate. The aggregate must list `EXTRACT0` in its `sources`, not the last formula node directly.
114
+
115
+ ## aggregate
116
+ ```json
117
+ {
118
+ "action": "aggregate",
119
+ "sources": ["EXTRACT0"],
120
+ "parameters": {
121
+ "aggregations": [
122
+ { "name": "TotalRevenue", "label": "Total Revenue", "action": "SUM", "source": "TotalAmount" }
123
+ ],
124
+ "groupings": ["AccountId", "Account.Name", "CloseYear", "CloseMonth"],
125
+ "nodeType": "STANDARD",
126
+ "pivots": []
127
+ }
128
+ }
129
+ ```
130
+ Every column in `groupings` must physically exist on the upstream node's output. Wave does NOT auto-derive `<Date>_Year` / `<Date>_Month` in mid-pipeline — derive them via formula nodes.
131
+
132
+ ## save
133
+ ```json
134
+ {
135
+ "action": "save",
136
+ "sources": ["AGGREGATE_BY_ACCOUNT_MONTH"],
137
+ "parameters": {
138
+ "dataset": {
139
+ "type": "analyticsDataset",
140
+ "label": "DTS_OpportunityRev",
141
+ "name": "DTS_OpportunityRev",
142
+ "folderName": "OpportunityInsights"
143
+ },
144
+ "fields": [],
145
+ "measuresToCurrencies": []
146
+ }
147
+ }
148
+ ```
149
+
150
+ ## Common errors → fixes
151
+
152
+ | Symptom | Cause | Fix |
153
+ |---------|-------|-----|
154
+ | Recipe in `New` status, `targetDataflowId` null | Multipart upload instead of `?format=R3` JSON | Re-create with `POST /wave/recipes?format=R3` |
155
+ | `500 "tableModelInfo is null"` | Multipart PATCH destroyed compiled state | DELETE recipe and re-POST via R3 JSON body |
156
+ | `400 "Recipe Definition JSON is missing"` | Sent multipart while using `?format=R3` | Use plain `application/json` with `recipeDefinition` embedded |
157
+ | Formula type mismatch at runtime | Wrong `type` (e.g. `"TEXT"`) on numeric formula | Use `type: "NUMBER"` and `expressionType: "SQL"` |
158
+ | Aggregate: `Can't find field _Year` | Auto-derived helpers don't exist mid-pipeline | Add formula node: `date_format(CloseDate, 'yyyy')` |
159
+ | `400 errorCode 276` on POST | Recipe name already exists | Use `PATCH /wave/recipes/{id}?format=R3` instead |
160
+ | `400 Id not found: 05v...` on dataflowjobs | Passed recipe id instead of `targetDataflowId` | Use the `02K...` id |
161
+ | Recipe opens with "Can't Load the Recipe" | Formula node has multiple fields | Split into chained single-field formula nodes |
162
+
163
+ ## Common wrong variants
164
+
165
+ The API silently rejects incorrect field names, function expressions, and structural keys. Use the values in the Correct column verbatim.
166
+
167
+ | Node type | Correct usage / field name | Common wrong variant |
168
+ |-----------|-------------------|----------------------|
169
+ | load | `parameters.dataset.type: "connectedDataset"` | `connection: "LocalSalesforce"` |
170
+ | load | `parameters.dataset.connectionName: "SFDC_LOCAL"` | `connection` key at top level |
171
+ | load | `parameters.dataset.sourceObjectName: "Opportunity"` | `object` key |
172
+ | filter | `parameters.filterExpressions[].operator: "EQUAL"` | `predicate.expression` |
173
+ | filter | `parameters.filterExpressions[].operands: ["Closed Won"]` | `value` key |
174
+ | join | `parameters.joinType: "LOOKUP"` | `"Left"`, `"INNER"` |
175
+ | join | `parameters.leftKeys: ["AccountId"]` | `leftKey` (singular) |
176
+ | join | `parameters.rightKeys: ["Id"]` | `rightKey` (singular) |
177
+ | join | `parameters.rightQualifier: "Account"` | `rightPrefix` |
178
+ | formula | `action: "formula"` | `"computeExpression"`, `"augmentColumns"`, `"transform"` |
179
+ | formula | `parameters.fields[].formulaExpression` | `formula`, `expression` |
180
+ | formula | `parameters.fields` (array key) | `"columns"` |
181
+ | formula | field names unquoted: `date_format(CloseDate, 'yyyy')` | backtick-quoted: `` date_format(`CloseDate`, 'yyyy') `` |
182
+ | formula | `parameters.fields[].type: "TEXT"` | `dataType` |
183
+ | formula | `date_format(CloseDate, 'yyyy')` | `TO_CHAR(CloseDate, 'YYYY')` |
184
+ | extractGrains | `parameters.grainExtractions: []` | `grainFields: [...]` |
185
+ | aggregate | `parameters.aggregations[].action: "SUM"` | `operation` |
186
+ | aggregate | `parameters.aggregations[].source: "Amount"` | `field` |
187
+ | aggregate | `parameters.groupings: [...]` | `groupBy` |
188
+ | aggregate | `parameters.nodeType: "STANDARD"` | absent |
189
+ | aggregate | `parameters.pivots: []` | absent |
190
+ | save | `parameters.dataset.folderName: "AppName"` | `app`, `folder` |
191
+ | save | `parameters.fields: []` | absent |
192
+ | save | `parameters.measuresToCurrencies: []` | absent |
193
+ | ui connectors | `{ "source": "A", "target": "B" }` | `{ "from": "A", "to": "B" }` |
194
+ | ui containers | `"graph": { ... }` | `"children": { ... }` |
@@ -0,0 +1,17 @@
1
+ # Scripts Reference
2
+
3
+ All scripts accept org alias as the first positional arg and read `accessToken` + `instanceUrl` from `sf org display -o <alias> --json`.
4
+
5
+ | Script | Purpose | Usage |
6
+ |--------|---------|-------|
7
+ | `scripts/create_app.js` | Idempotently create a CRMA app (folder). Lists existing folders first; only creates if missing. Do NOT include `assetIcon`. | `node scripts/create_app.js <alias> <appName>` |
8
+ | `scripts/deploy_and_run_recipe.js` | Create + compile + run a recipe end-to-end. POSTs or PATCHes via `findExistingRecipe` upsert logic. Polls until terminal. | `node scripts/deploy_and_run_recipe.js <alias> <recipeFile.wdpr> <appName> <recipeName>` |
9
+ | `scripts/probe_recipe.js` | Fetch a recipe with `?format=R3` and print compile status + `targetDataflowId`. | `node scripts/probe_recipe.js <alias> <recipeId>` |
10
+ | `scripts/fetch_dataset_xmd.js` | Look up a dataset by name and dump its current-version XMD (dimensions / measures / dates). Use before generating synthetic data so the schema matches. | `node scripts/fetch_dataset_xmd.js <alias> <datasetName>` |
11
+ | `scripts/gen_synthetic_dataset.js` | Schema-driven synthetic data generator. Reads live XMD, classifies each column by name pattern (`*Id`, `*Industry`, `*Year`, `*Amount`), renames dotted fields to underscored, writes CSV + Wave-compatible `metadata.json`. Default target: `<source>_Synthetic`. | `node scripts/gen_synthetic_dataset.js <alias> <sourceDatasetName>` |
12
+ | `scripts/upload_dataset.js` | Upload a CSV to a new or existing dataset via InsightsExternalData API. Polls until ingestion is terminal. | `node scripts/upload_dataset.js <alias> <csvPath> <metadataJsonPath> <datasetAlias> <folderId>` |
13
+ | `scripts/verify_synthetic_dataset.js` | Post-upload SAQL sanity checks: row count, sum/min/max, edge-case counts, group-by-dimension breakdowns. | `node scripts/verify_synthetic_dataset.js <alias> <datasetId> <versionId>` |
14
+ | `scripts/find_unused_dataset_fields.js` | Given a dataset name, list every XMD field that no dashboard SAQL references. | `node scripts/find_unused_dataset_fields.js <alias> <datasetName>` |
15
+ | `scripts/verify_dashboard_steps.js` | For each step in a deployed dashboard, GET stored SAQL, decode HTML entities, swap dataset name for `id/version`, POST to `/wave/query`. Prints HTTP status + row count per step. Update `DASH_ID` and `DS` constants at the top before running. | `node scripts/verify_dashboard_steps.js <alias>` |
16
+ | `scripts/patch_t_oppo_view.js` | Full-cycle PATCH template: GET state → `decodeAll` every step query → merge dark theme → PATCH. Use as the canonical pattern for future dashboard PATCH scripts. | `node scripts/patch_t_oppo_view.js <alias>` |
17
+ | `scripts/check_connector_error.sh` | GET `/wave/dataConnectors/{id}`, parse `errorCategory`, exit 0 if `System` (safe to retry), exit 1 if `User`/`Limit`/unknown (do not retry — fix config first). | `bash scripts/check_connector_error.sh <alias> <connectorId>` |
@@ -0,0 +1,68 @@
1
+ # Designer-Native UI Section
2
+
3
+ The Recipe Designer detects old-style `ui` sections (old `position: {x,y}` format, empty `connectors: []`, no `type` on nodes) and shows the "Can't Load the Recipe" repair popup on every open. Always use the Designer-native format below.
4
+
5
+ ## Node type mappings
6
+
7
+ | Recipe node | UI `type` |
8
+ |-------------|-----------|
9
+ | `load` | `LOAD_DATASET` |
10
+ | `filter` | `FILTER` |
11
+ | `join` | `JOIN` |
12
+ | `formula` (all chained) | Grouped inside a `TRANSFORM` UI node |
13
+ | `extractGrains` | Inside the `AGGREGATE` UI node's inner `graph` |
14
+ | `aggregate` | Inside the `AGGREGATE` UI node's inner `graph` |
15
+ | `save` | `OUTPUT` |
16
+
17
+ ## Rules for grouping formula nodes into TRANSFORM
18
+
19
+ - ALL consecutive formula nodes go into a single `TRANSFORM0` UI node.
20
+ - The inner `graph` lists every formula node name with `{ "parameters": { "type": "BASE_FORMULA_UI" } }`.
21
+ - The inner `connectors` list the formula→formula chain.
22
+ - The outer `connectors` only reference UI node names (TRANSFORM0, AGGREGATE0, etc.), never individual internal formula node names.
23
+ - Always insert `EXTRACT0` (`extractGrains`) inside the AGGREGATE UI node's `graph`. The aggregate node must source from EXTRACT0, not the last formula node.
24
+
25
+ ## Template (filter → join → 2 formula nodes → aggregate → save)
26
+
27
+ ```json
28
+ "ui": {
29
+ "nodes": {
30
+ "LOAD_OPPORTUNITY": { "label": "Opportunity", "type": "LOAD_DATASET", "top": 112, "left": 112, "parameters": { "sampleSize": 2000 } },
31
+ "LOAD_ACCOUNT": { "label": "Account", "type": "LOAD_DATASET", "top": 252, "left": 252, "parameters": { "sampleSize": 2000 } },
32
+ "FILTER_CLOSED_WON": { "label": "FILTER_CLOSED_WON", "type": "FILTER", "top": 112, "left": 252 },
33
+ "JOIN_ACCOUNT": { "label": "JOIN_ACCOUNT", "type": "JOIN", "top": 112, "left": 392 },
34
+ "TRANSFORM0": {
35
+ "label": "TRANSFORM0", "type": "TRANSFORM", "top": 112, "left": 532,
36
+ "graph": {
37
+ "COMPUTE_YEAR": { "parameters": { "type": "BASE_FORMULA_UI" } },
38
+ "COMPUTE_MONTH": { "parameters": { "type": "BASE_FORMULA_UI" } }
39
+ },
40
+ "connectors": [{ "source": "COMPUTE_YEAR", "target": "COMPUTE_MONTH" }]
41
+ },
42
+ "AGGREGATE0": {
43
+ "label": "AGGREGATE0", "type": "AGGREGATE", "top": 112, "left": 672,
44
+ "graph": { "EXTRACT0": null, "AGGREGATE_NODE": null },
45
+ "connectors": [{ "source": "EXTRACT0", "target": "AGGREGATE_NODE" }]
46
+ },
47
+ "OUTPUT_DATASET": { "label": "OUTPUT_DATASET", "type": "OUTPUT", "top": 112, "left": 812 }
48
+ },
49
+ "connectors": [
50
+ { "source": "LOAD_OPPORTUNITY", "target": "FILTER_CLOSED_WON" },
51
+ { "source": "FILTER_CLOSED_WON", "target": "JOIN_ACCOUNT" },
52
+ { "source": "LOAD_ACCOUNT", "target": "JOIN_ACCOUNT" },
53
+ { "source": "JOIN_ACCOUNT", "target": "TRANSFORM0" },
54
+ { "source": "TRANSFORM0", "target": "AGGREGATE0" },
55
+ { "source": "AGGREGATE0", "target": "OUTPUT_DATASET" }
56
+ ],
57
+ "hiddenColumns": []
58
+ }
59
+ ```
60
+
61
+ Also add at the top level of `recipeDefinition`:
62
+ - `"runMode": "full"`
63
+
64
+ Add on load nodes: `"mode": "SYNCED"`, `"preserveCurrencyFields": []`, `"sampleDetails": {"type":"TopN","sortBy":[]}`
65
+
66
+ Add on save node: `"measuresToCurrencies": []`
67
+
68
+ Add on aggregate node: `"nodeType": "STANDARD"`, `"pivots": []`
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bash
2
+ # GET /wave/dataConnectors/{id}, parse errorCategory, exit 0 if safe to retry (System), 1 if not (User/Limit/unknown).
3
+ # Usage: bash scripts/check_connector_error.sh <alias> <connectorId>
4
+ # Exit codes: 0 = System error (safe to retry) | 1 = User/Limit/unknown (do not retry, fix config first)
5
+
6
+ set -euo pipefail
7
+
8
+ ALIAS="${1:?Usage: check_connector_error.sh <alias> <connectorId>}"
9
+ CONNECTOR_ID="${2:?Usage: check_connector_error.sh <alias> <connectorId>}"
10
+
11
+ ORG_JSON=$(sf org display -o "$ALIAS" --json 2>/dev/null)
12
+ ACCESS_TOKEN=$(echo "$ORG_JSON" | grep -o '"accessToken": *"[^"]*"' | cut -d'"' -f4)
13
+ INSTANCE_URL=$(echo "$ORG_JSON" | grep -o '"instanceUrl": *"[^"]*"' | cut -d'"' -f4)
14
+
15
+ if [[ -z "$ACCESS_TOKEN" || -z "$INSTANCE_URL" ]]; then
16
+ echo '{"error":"Could not retrieve org credentials. Run: sf org display -o <alias>"}' >&2
17
+ exit 1
18
+ fi
19
+
20
+ RESPONSE=$(curl -s -f \
21
+ -H "Authorization: Bearer $ACCESS_TOKEN" \
22
+ -H "Content-Type: application/json" \
23
+ "${INSTANCE_URL}/services/data/v59.0/wave/dataConnectors/${CONNECTOR_ID}")
24
+
25
+ ERROR_CATEGORY=$(echo "$RESPONSE" | grep -o '"errorCategory":"[^"]*"' | cut -d'"' -f4)
26
+ ERROR_MESSAGE=$(echo "$RESPONSE" | grep -o '"errorMessage":"[^"]*"' | cut -d'"' -f4)
27
+ STATUS=$(echo "$RESPONSE" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
28
+
29
+ echo "{\"connectorId\":\"$CONNECTOR_ID\",\"status\":\"$STATUS\",\"errorCategory\":\"$ERROR_CATEGORY\",\"errorMessage\":\"$ERROR_MESSAGE\"}"
30
+
31
+ case "$ERROR_CATEGORY" in
32
+ System)
33
+ echo "errorCategory=System: platform-side issue — safe to retry." >&2
34
+ exit 0
35
+ ;;
36
+ User)
37
+ echo "errorCategory=User: config or credential issue — retry will not fix this. Check connector settings." >&2
38
+ exit 1
39
+ ;;
40
+ Limit)
41
+ echo "errorCategory=Limit: org quota exceeded — retry will not fix this. Check org limits." >&2
42
+ exit 1
43
+ ;;
44
+ *)
45
+ # Unknown category — check for generic "contact support" message
46
+ if echo "$ERROR_MESSAGE" | grep -qi "contact support"; then
47
+ echo "errorCategory unknown but message contains 'contact support' — treating as System, safe to retry once." >&2
48
+ exit 0
49
+ fi
50
+ echo "errorCategory='$ERROR_CATEGORY' unrecognised — treating as non-retryable." >&2
51
+ exit 1
52
+ ;;
53
+ esac
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ // Idempotently create a CRMA app (folder). Lists existing folders first; only creates if missing.
3
+ // Do NOT include assetIcon — it is rejected by the Wave REST API.
4
+ // Usage: node scripts/create_app.js <alias> <appName>
5
+
6
+ const { execSync, execFileSync } = require("child_process");
7
+
8
+ function getOrgCredentials(alias) {
9
+ const result = execFileSync('sf', ['org', 'display', '-o', alias, '--json'], { encoding: "utf8" });
10
+ const json = JSON.parse(result);
11
+ return { accessToken: json.result.accessToken, instanceUrl: json.result.instanceUrl };
12
+ }
13
+
14
+ function apiFetch(instanceUrl, accessToken, path, method = "GET", body = null) {
15
+ const url = `${instanceUrl}${path}`;
16
+ const args = [
17
+ "-s",
18
+ "-H", `Authorization: Bearer ${accessToken}`,
19
+ "-H", "Content-Type: application/json",
20
+ "-X", method,
21
+ "-w", "\\nHTTP_STATUS:%{http_code}",
22
+ ];
23
+ if (body) args.push("-d", JSON.stringify(body));
24
+ args.push(url);
25
+ const raw = execSync(`curl ${args.map(a => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ")}`, { encoding: "utf8" });
26
+ const parts = raw.split("\nHTTP_STATUS:");
27
+ const httpStatus = parseInt(parts[1] || "0", 10);
28
+ if (httpStatus < 200 || httpStatus >= 300) {
29
+ throw new Error(`HTTP ${httpStatus} on ${method} ${path}: ${parts[0]}`);
30
+ }
31
+ return JSON.parse(parts[0]);
32
+ }
33
+
34
+ const [,, alias, appName] = process.argv;
35
+ if (!alias || !appName) {
36
+ console.error("Usage: node create_app.js <alias> <appName>");
37
+ process.exit(1);
38
+ }
39
+
40
+ const { accessToken, instanceUrl } = getOrgCredentials(alias);
41
+
42
+ // List existing folders
43
+ const listResult = apiFetch(instanceUrl, accessToken, "/services/data/v59.0/wave/folders");
44
+ const folders = listResult.folders || [];
45
+ const existing = folders.find(f => f.name === appName || f.developerName === appName);
46
+
47
+ if (existing) {
48
+ console.log(JSON.stringify({ status: "exists", folderId: existing.id, folderName: existing.name }));
49
+ process.exit(0);
50
+ }
51
+
52
+ // Create the folder
53
+ const created = apiFetch(instanceUrl, accessToken, "/services/data/v59.0/wave/folders", "POST", {
54
+ name: appName,
55
+ developerName: appName,
56
+ folderType: "Shared",
57
+ });
58
+
59
+ console.log(JSON.stringify({ status: "created", folderId: created.id, folderName: created.name }));
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // Create + compile + run a Wave Recipe end-to-end via REST API at ?format=R3.
3
+ // POST or PATCH the recipe, assert targetDataflowId is returned, trigger a Run Now, poll until terminal.
4
+ // Usage: node scripts/deploy_and_run_recipe.js <alias> <recipeFile.wdpr> <appName> <recipeName>
5
+
6
+ const { execSync, execFileSync } = require("child_process");
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+
10
+ const TERMINAL_STATUSES = new Set(["Success", "Failure", "Warning", "Cancelled"]);
11
+ const POLL_INTERVAL_MS = 10000;
12
+ const MAX_WAIT_MS = 20 * 60 * 1000;
13
+
14
+ function getOrgCredentials(alias) {
15
+ const result = execFileSync('sf', ['org', 'display', '-o', alias, '--json'], { encoding: "utf8" });
16
+ const json = JSON.parse(result);
17
+ return { accessToken: json.result.accessToken, instanceUrl: json.result.instanceUrl };
18
+ }
19
+
20
+ function apiFetch(instanceUrl, accessToken, path, method = "GET", body = null) {
21
+ const url = `${instanceUrl}${path}`;
22
+ const args = [
23
+ "-s",
24
+ "-H", `Authorization: Bearer ${accessToken}`,
25
+ "-H", "Content-Type: application/json",
26
+ "-X", method,
27
+ "-w", "\\nHTTP_STATUS:%{http_code}",
28
+ ];
29
+ if (body !== null) args.push("-d", JSON.stringify(body));
30
+ args.push(url);
31
+ const raw = execSync(`curl ${args.map(a => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ")}`, {
32
+ encoding: "utf8",
33
+ maxBuffer: 10 * 1024 * 1024,
34
+ });
35
+ const parts = raw.split("\nHTTP_STATUS:");
36
+ const httpStatus = parseInt(parts[1] || "0", 10);
37
+ if (httpStatus < 200 || httpStatus >= 300) {
38
+ throw new Error(`HTTP ${httpStatus} on ${method} ${path}: ${parts[0]}`);
39
+ }
40
+ return JSON.parse(parts[0]);
41
+ }
42
+
43
+ function sleep(ms) {
44
+ execSync(`sleep ${ms / 1000}`);
45
+ }
46
+
47
+ const [,, alias, recipeFile, appName, recipeName] = process.argv;
48
+ if (!alias || !recipeFile || !appName || !recipeName) {
49
+ console.error("Usage: node deploy_and_run_recipe.js <alias> <recipeFile.wdpr> <appName> <recipeName>");
50
+ process.exit(1);
51
+ }
52
+
53
+ const { accessToken, instanceUrl } = getOrgCredentials(alias);
54
+
55
+ // Read the .wdpr file — it contains only the recipeDefinition object
56
+ const recipeDefinition = JSON.parse(fs.readFileSync(path.resolve(recipeFile), "utf8"));
57
+
58
+ // Look up folder id
59
+ const folderList = apiFetch(instanceUrl, accessToken, "/services/data/v59.0/wave/folders");
60
+ const folder = (folderList.folders || []).find(f => f.name === appName || f.developerName === appName);
61
+ if (!folder) {
62
+ console.error(`Folder "${appName}" not found. Run create_app.js first.`);
63
+ process.exit(1);
64
+ }
65
+
66
+ // Find existing recipe by name
67
+ const recipeList = apiFetch(instanceUrl, accessToken, "/services/data/v59.0/wave/recipes");
68
+ const existingRecipe = (recipeList.recipes || []).find(r => r.name === recipeName || r.label === recipeName);
69
+
70
+ let recipe;
71
+ if (existingRecipe) {
72
+ // PATCH at ?format=R3
73
+ recipe = apiFetch(instanceUrl, accessToken,
74
+ `/services/data/v59.0/wave/recipes/${existingRecipe.id}?format=R3`,
75
+ "PATCH", { recipeDefinition }
76
+ );
77
+ } else {
78
+ // POST at ?format=R3
79
+ recipe = apiFetch(instanceUrl, accessToken,
80
+ `/services/data/v59.0/wave/recipes?format=R3`,
81
+ "POST", {
82
+ recipeDefinition,
83
+ name: recipeName,
84
+ label: recipeName,
85
+ folder: { id: folder.id },
86
+ }
87
+ );
88
+ }
89
+
90
+ const targetDataflowId = recipe.targetDataflowId;
91
+ if (!targetDataflowId) {
92
+ console.error("targetDataflowId is null — recipe did not compile. Check recipeDefinition structure.");
93
+ console.error(JSON.stringify(recipe, null, 2));
94
+ process.exit(1);
95
+ }
96
+
97
+ console.log(`Recipe id: ${recipe.id}, targetDataflowId: ${targetDataflowId}`);
98
+
99
+ // Trigger Run Now
100
+ const job = apiFetch(instanceUrl, accessToken, "/services/data/v59.0/wave/dataflowjobs", "POST", {
101
+ dataflowId: targetDataflowId,
102
+ command: "start",
103
+ });
104
+
105
+ const jobId = job.id;
106
+ console.log(`Dataflow job started: ${jobId}`);
107
+
108
+ // Poll until terminal
109
+ const deadline = Date.now() + MAX_WAIT_MS;
110
+ while (Date.now() < deadline) {
111
+ sleep(POLL_INTERVAL_MS);
112
+ const jobStatus = apiFetch(instanceUrl, accessToken, `/services/data/v59.0/wave/dataflowjobs/${jobId}`);
113
+ const status = jobStatus.status;
114
+ console.log(`Job status: ${status}`);
115
+ if (TERMINAL_STATUSES.has(status)) {
116
+ console.log(JSON.stringify({ jobId, status, recipeId: recipe.id, targetDataflowId }));
117
+ process.exit(status === "Success" || status === "Warning" ? 0 : 1);
118
+ }
119
+ }
120
+
121
+ console.error("Timed out waiting for job to complete.");
122
+ process.exit(1);
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ // Look up a dataset by name and dump its current-version XMD (dimensions / measures / dates).
3
+ // Use before generating synthetic data so the schema matches the live dataset.
4
+ // Usage: node scripts/fetch_dataset_xmd.js <alias> <datasetName>
5
+
6
+ const { execSync, execFileSync } = require("child_process");
7
+
8
+ function getOrgCredentials(alias) {
9
+ const result = execFileSync('sf', ['org', 'display', '-o', alias, '--json'], { encoding: "utf8" });
10
+ const json = JSON.parse(result);
11
+ return { accessToken: json.result.accessToken, instanceUrl: json.result.instanceUrl };
12
+ }
13
+
14
+ function apiFetch(instanceUrl, accessToken, path) {
15
+ const url = `${instanceUrl}${path}`;
16
+ const args = [
17
+ "-s",
18
+ "-H", `Authorization: Bearer ${accessToken}`,
19
+ "-w", "\\nHTTP_STATUS:%{http_code}",
20
+ url,
21
+ ];
22
+ const raw = execSync(`curl ${args.map(a => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ")}`, {
23
+ encoding: "utf8",
24
+ maxBuffer: 10 * 1024 * 1024,
25
+ });
26
+ const parts = raw.split("\nHTTP_STATUS:");
27
+ const httpStatus = parseInt(parts[1] || "0", 10);
28
+ if (httpStatus < 200 || httpStatus >= 300) {
29
+ throw new Error(`HTTP ${httpStatus} on GET ${path}: ${parts[0]}`);
30
+ }
31
+ return JSON.parse(parts[0]);
32
+ }
33
+
34
+ const [,, alias, datasetName] = process.argv;
35
+ if (!alias || !datasetName) {
36
+ console.error("Usage: node fetch_dataset_xmd.js <alias> <datasetName>");
37
+ process.exit(1);
38
+ }
39
+
40
+ const { accessToken, instanceUrl } = getOrgCredentials(alias);
41
+
42
+ // Find the dataset by name
43
+ const datasets = apiFetch(instanceUrl, accessToken, `/services/data/v59.0/wave/datasets`);
44
+ const dataset = (datasets.datasets || []).find(d => d.name === datasetName || d.developerName === datasetName);
45
+ if (!dataset) {
46
+ console.error(`Dataset "${datasetName}" not found.`);
47
+ process.exit(1);
48
+ }
49
+
50
+ // Fetch XMD for the current version
51
+ const versionId = dataset.currentVersionId;
52
+ const xmd = apiFetch(instanceUrl, accessToken, `/services/data/v59.0/wave/datasets/${dataset.id}/versions/${versionId}/xmd`);
53
+
54
+ console.log(JSON.stringify({
55
+ datasetId: dataset.id,
56
+ datasetName: dataset.name,
57
+ versionId,
58
+ xmd,
59
+ }, null, 2));