@salesforce/ui-bundle-template-feature-react-search 11.32.0 → 11.33.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/dist/CHANGELOG.md +9 -0
- package/dist/force-app/main/default/uiBundles/feature-react-search/package.json +4 -4
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/dist/scripts/org-setup-utils.mjs +206 -36
- package/dist/scripts/org-setup.mjs +180 -69
- package/package.json +1 -1
package/dist/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
## [11.33.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.32.0...v11.33.0) (2026-07-21)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **org-setup:** harden org-setup.mjs against SOQL/Apex/CLI injection (W-23043731) ([#751](https://github.com/salesforce-experience-platform-emu/webapps/issues/751)) ([e820204](https://github.com/salesforce-experience-platform-emu/webapps/commit/e8202049e262ddbb47235cbedf5ccf54fe6a4d31))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
6
15
|
## [11.32.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.31.15...v11.32.0) (2026-07-21)
|
|
7
16
|
|
|
8
17
|
**Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"graphql:schema": "node scripts/get-graphql-schema.mjs"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@salesforce/platform-sdk": "^11.
|
|
22
|
-
"@salesforce/ui-bundle": "^11.
|
|
21
|
+
"@salesforce/platform-sdk": "^11.33.0",
|
|
22
|
+
"@salesforce/ui-bundle": "^11.33.0",
|
|
23
23
|
"@tailwindcss/vite": "^4.1.17",
|
|
24
24
|
"class-variance-authority": "^0.7.1",
|
|
25
25
|
"clsx": "^2.1.1",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"@graphql-eslint/eslint-plugin": "^4.1.0",
|
|
45
45
|
"@graphql-tools/utils": "^11.0.0",
|
|
46
46
|
"@playwright/test": "^1.49.0",
|
|
47
|
-
"@salesforce/graphiti": "^11.
|
|
48
|
-
"@salesforce/vite-plugin-ui-bundle": "^11.
|
|
47
|
+
"@salesforce/graphiti": "^11.33.0",
|
|
48
|
+
"@salesforce/vite-plugin-ui-bundle": "^11.33.0",
|
|
49
49
|
"@testing-library/jest-dom": "^6.6.3",
|
|
50
50
|
"@testing-library/react": "^16.1.0",
|
|
51
51
|
"@testing-library/user-event": "^14.5.2",
|
package/dist/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/ui-bundle-template-base-sfdx-project",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.33.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@salesforce/ui-bundle-template-base-sfdx-project",
|
|
9
|
-
"version": "11.
|
|
9
|
+
"version": "11.33.0",
|
|
10
10
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"fast-xml-parser": "^5.9.3",
|
package/dist/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared helpers for the org-setup scripts
|
|
2
|
+
* Shared helpers for the org-setup scripts.
|
|
3
3
|
*
|
|
4
4
|
* Factored out of org-setup.mjs so BOTH `org-setup.mjs` and `org-setup-dev.mjs` import the
|
|
5
5
|
* same primitives instead of duplicating them:
|
|
6
6
|
* - process runners (`run`, `runAsync`, `spawnAsync`)
|
|
7
7
|
* - SFDX path + UI-bundle discovery (`resolveSfdxSource`, `discoverAllUIBundleDirs`,
|
|
8
|
-
* `discoverUIBundleDir` — with the multi-bundle acknowledgment
|
|
8
|
+
* `discoverUIBundleDir` — with the multi-bundle acknowledgment)
|
|
9
9
|
* - the interactive `--target-org` fallback (`resolveTargetOrg`) and its
|
|
10
10
|
* single-select picker (`promptSelect`)
|
|
11
11
|
* - pure, unit-testable logic for the license gate, SOQL-name validation, org-list
|
|
@@ -27,35 +27,207 @@ import { resolve } from 'node:path';
|
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* Validate that a
|
|
31
|
-
* '<
|
|
32
|
-
*
|
|
33
|
-
* profile
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
30
|
+
* Validate that a config-authored identifier is safe to interpolate into a SOQL
|
|
31
|
+
* `WHERE <field> = '<value>'` clause (validate-and-fail with a strict whitelist,
|
|
32
|
+
* NOT an escaper). These values (role name, site name,
|
|
33
|
+
* self-reg profile name) are developer-authored identifiers the app ships, not
|
|
34
|
+
* end-user data, and legitimate identifiers are drawn from letters, digits,
|
|
35
|
+
* spaces, hyphens, and underscores. Anything outside that set — a `'`, `\`, `=`,
|
|
36
|
+
* `"`, `&`, `<`, `>`, punctuation, or a control char — is treated as a config
|
|
37
|
+
* mistake to surface loudly rather than a value to silently escape. The
|
|
38
|
+
* whitelist is deliberately broader than the injection characters alone so a
|
|
39
|
+
* single positive rule covers every SOQL/quoting hazard at once.
|
|
40
|
+
*
|
|
41
|
+
* NOTE: this is for config *identifiers*. Free-form display values that
|
|
42
|
+
* legitimately contain punctuation (e.g. an Account name like `O'Brien Rentals`)
|
|
43
|
+
* are escaped at their call site instead — see org-setup.mjs's Account path.
|
|
44
|
+
*
|
|
45
|
+
* @param {*} value the value to validate (coerced to string)
|
|
46
|
+
* @param {string} fieldLabel how to name the field in an error (e.g. "roleName")
|
|
47
|
+
* @param {object} [opts]
|
|
48
|
+
* @param {string} [opts.remediation] where the author fixes it, e.g.
|
|
49
|
+
* "org-setup.config.json" or "the networks/<siteName>.network-meta.xml filename".
|
|
50
|
+
* Defaults to org-setup.config.json.
|
|
51
|
+
* @returns {string} the value unchanged when valid; throws otherwise.
|
|
52
|
+
*/
|
|
53
|
+
export function validateSoqlName(value, fieldLabel, opts = {}) {
|
|
54
|
+
const { remediation = 'org-setup.config.json' } = opts;
|
|
55
|
+
const s = String(value);
|
|
56
|
+
if (!/^[A-Za-z0-9 _-]+$/.test(s)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`${fieldLabel} "${value}" contains an unsupported character ` +
|
|
59
|
+
`(only letters, digits, spaces, hyphens, and underscores are allowed). ` +
|
|
60
|
+
`Fix it in ${remediation}.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return s;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Back-compat wrapper for the self-reg profile name (the original call site).
|
|
68
|
+
* Delegates to the strict whitelist above; kept as a named export so
|
|
69
|
+
* existing importers and tests need no change beyond the tightened rule.
|
|
37
70
|
*/
|
|
38
71
|
export function validateProfileNameForSoql(name) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
72
|
+
return validateSoqlName(name, 'selfRegProfile name');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Escape a free-form value for safe interpolation inside a single-quoted SOQL
|
|
77
|
+
* string literal. Unlike validateSoqlName, this is for display
|
|
78
|
+
* values that legitimately contain punctuation — e.g. an Account name like
|
|
79
|
+
* `O'Brien Rentals & Co.` — where rejecting the value would be wrong. Escapes
|
|
80
|
+
* the backslash FIRST (so an already-present `\` isn't double-counted) then the
|
|
81
|
+
* quote, matching SOQL's `\'` / `\\` escape rules. Returns the escaped inner
|
|
82
|
+
* string (no surrounding quotes — the caller supplies those).
|
|
83
|
+
*/
|
|
84
|
+
export function escapeSoqlString(value) {
|
|
85
|
+
return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate a Salesforce API name (an SObject type or a field API name) that is
|
|
90
|
+
* interpolated as a bare identifier into generated Apex / dynamic SOQL — e.g.
|
|
91
|
+
* `new ${sobject}()`, `r.put('${field}', …)`, or `Database.query('SELECT Id FROM
|
|
92
|
+
* ${sobject} …')`. Unlike a WHERE value, an identifier can't be
|
|
93
|
+
* quoted-and-escaped, so the only safe treatment is a strict whitelist. Valid API
|
|
94
|
+
* names are letters, digits, and underscores only (the `__c` / `__r` custom and
|
|
95
|
+
* namespace suffixes are already covered by that set); anything else — a quote,
|
|
96
|
+
* space, dot, or `)` — would let a crafted seed-data field name or sobject break
|
|
97
|
+
* out of the surrounding statement, so it is rejected loudly. These identifiers
|
|
98
|
+
* come from the app-shipped data plan / seed JSON, so a violation is a fixture
|
|
99
|
+
* bug, not user input; this keeps the identifier hardening symmetric with the
|
|
100
|
+
* value escaping (apexLiteral / escapeSoqlString) already applied around them.
|
|
101
|
+
*
|
|
102
|
+
* @param {*} value the identifier to validate (coerced to string)
|
|
103
|
+
* @param {string} fieldLabel how to name it in an error (e.g. "sobject type")
|
|
104
|
+
* @returns {string} the value unchanged when valid; throws otherwise.
|
|
105
|
+
*/
|
|
106
|
+
export function validateApiName(value, fieldLabel) {
|
|
107
|
+
const s = String(value);
|
|
108
|
+
if (!/^[A-Za-z0-9_]+$/.test(s)) {
|
|
43
109
|
throw new Error(
|
|
44
|
-
|
|
45
|
-
`(
|
|
46
|
-
`
|
|
110
|
+
`${fieldLabel} "${value}" is not a valid Salesforce API name ` +
|
|
111
|
+
`(only letters, digits, and underscores are allowed). ` +
|
|
112
|
+
`Check the seed data plan (data-plan.json) and record field names.`,
|
|
47
113
|
);
|
|
48
114
|
}
|
|
49
115
|
return s;
|
|
50
116
|
}
|
|
51
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Parse the single `SETUP_RESULT_JSON:` marker line that buildApexInsert emits
|
|
120
|
+
* from anonymous Apex. Replaces the old per-record REF:/ERR: debug
|
|
121
|
+
* scraping. The marker carries a JSON array of `{ ref, id? , err? }` — one entry
|
|
122
|
+
* per inserted record.
|
|
123
|
+
*
|
|
124
|
+
* Robustness is the whole point: a debug log can be truncated, so a missing
|
|
125
|
+
* marker or a payload that doesn't parse as the expected array is reported as a
|
|
126
|
+
* hard failure via `ok: false` rather than silently reading as "0 records, 0
|
|
127
|
+
* errors" (which would let a partial import look successful).
|
|
128
|
+
*
|
|
129
|
+
* @param {string} apexOut raw stdout from `sf apex run`
|
|
130
|
+
* @returns {{ ok: boolean, error: string|null,
|
|
131
|
+
* successes: Array<{ref:string, id:string}>,
|
|
132
|
+
* errors: Array<{ref:string, message:string}> }}
|
|
133
|
+
*/
|
|
134
|
+
export function parseApexInsertResults(apexOut) {
|
|
135
|
+
const text = String(apexOut ?? '');
|
|
136
|
+
// The line looks like: `...|DEBUG|SETUP_RESULT_JSON:[{"ref":"a","id":"001..."}]`.
|
|
137
|
+
// Anchor on the marker and take the rest of that line.
|
|
138
|
+
const markerIdx = text.indexOf('SETUP_RESULT_JSON:');
|
|
139
|
+
if (markerIdx === -1) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
error: 'no SETUP_RESULT_JSON marker found in Apex output (log may be truncated or the insert did not run)',
|
|
143
|
+
successes: [],
|
|
144
|
+
errors: [],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const afterMarker = text.slice(markerIdx + 'SETUP_RESULT_JSON:'.length);
|
|
148
|
+
// The payload runs to the end of the debug line; stop at the first newline.
|
|
149
|
+
const payload = afterMarker.split('\n')[0].trim();
|
|
150
|
+
|
|
151
|
+
let parsed;
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(payload);
|
|
154
|
+
} catch {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: 'SETUP_RESULT_JSON payload was not valid JSON (log line may be truncated)',
|
|
158
|
+
successes: [],
|
|
159
|
+
errors: [],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (!Array.isArray(parsed)) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: 'SETUP_RESULT_JSON payload was not a JSON array',
|
|
166
|
+
successes: [],
|
|
167
|
+
errors: [],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const successes = [];
|
|
172
|
+
const errors = [];
|
|
173
|
+
for (const entry of parsed) {
|
|
174
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
175
|
+
const ref = entry.ref != null ? String(entry.ref) : '';
|
|
176
|
+
if (entry.err != null) {
|
|
177
|
+
errors.push({ ref, message: String(entry.err) });
|
|
178
|
+
} else if (entry.id != null) {
|
|
179
|
+
successes.push({ ref, id: String(entry.id) });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, error: null, successes, errors };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Group serialized-record sizes into batches whose combined Apex character count
|
|
187
|
+
* (fixed per-batch overhead + each record's rendered size) stays under
|
|
188
|
+
* `charLimit`, subject to `maxBatch` records per batch. Replaces the
|
|
189
|
+
* old `estCharsPerRec = 40 + fields*55` heuristic with a measurement the caller
|
|
190
|
+
* supplies via `recordSizes`.
|
|
191
|
+
*
|
|
192
|
+
* Guarantees at least one record per batch even when a single record's size (plus
|
|
193
|
+
* overhead) already exceeds `charLimit` — otherwise a large record would loop
|
|
194
|
+
* forever or emit an empty batch. That oversized record is placed alone in its
|
|
195
|
+
* own batch and left for Apex to accept or reject.
|
|
196
|
+
*
|
|
197
|
+
* @param {number[]} recordSizes rendered Apex char length of each record, in order
|
|
198
|
+
* @param {object} opts
|
|
199
|
+
* @param {number} opts.charLimit max total chars per batch (record chars + overhead)
|
|
200
|
+
* @param {number} opts.maxBatch max records per batch
|
|
201
|
+
* @param {number} [opts.overhead] fixed per-batch char cost (DML boilerplate); default 0
|
|
202
|
+
* @returns {Array<{start:number, count:number}>} batch slices over the input order
|
|
203
|
+
*/
|
|
204
|
+
export function planApexBatches(recordSizes, { charLimit, maxBatch, overhead = 0 }) {
|
|
205
|
+
const batches = [];
|
|
206
|
+
let start = 0;
|
|
207
|
+
const n = recordSizes.length;
|
|
208
|
+
while (start < n) {
|
|
209
|
+
let count = 0;
|
|
210
|
+
let total = overhead;
|
|
211
|
+
while (start + count < n && count < maxBatch) {
|
|
212
|
+
const next = recordSizes[start + count];
|
|
213
|
+
// Always take at least one record, even if it alone busts the limit.
|
|
214
|
+
if (count > 0 && total + next > charLimit) break;
|
|
215
|
+
total += next;
|
|
216
|
+
count += 1;
|
|
217
|
+
}
|
|
218
|
+
batches.push({ start, count });
|
|
219
|
+
start += count;
|
|
220
|
+
}
|
|
221
|
+
return batches;
|
|
222
|
+
}
|
|
223
|
+
|
|
52
224
|
/**
|
|
53
225
|
* Decide whether the self-reg profile's UserLicense is satisfied, from the rows
|
|
54
226
|
* a `SELECT UserLicense.LicenseDefinitionKey, UserLicense.Name, UserLicense.Status,
|
|
55
227
|
* UserLicense.TotalLicenses, UserLicense.UsedLicenses FROM Profile WHERE Name = '…'`
|
|
56
|
-
* query returns
|
|
57
|
-
* compare two fields
|
|
58
|
-
* `LicenseDefinitionKey`, never the display Name
|
|
228
|
+
* query returns. The seat math MUST be done here in JS — SOQL cannot
|
|
229
|
+
* compare two fields. Matches/report on the stable
|
|
230
|
+
* `LicenseDefinitionKey`, never the display Name.
|
|
59
231
|
*
|
|
60
232
|
* satisfied ⇔ Status === 'Active' && (total === -1 [unlimited sentinel] || total - used > 0).
|
|
61
233
|
* 0 rows / null UserLicense ⇒ not satisfied, with a reason naming the profile.
|
|
@@ -85,7 +257,7 @@ export function evaluateLicenseRows(rows, profileName) {
|
|
|
85
257
|
if (!Number.isFinite(total) || !Number.isFinite(used)) {
|
|
86
258
|
return { satisfied: false, reason: `could not read seat counts for license ${label} (${key})`, license };
|
|
87
259
|
}
|
|
88
|
-
// -1 is the documented "unlimited" sentinel (defensive — unverified live
|
|
260
|
+
// -1 is the documented "unlimited" sentinel (defensive — unverified live).
|
|
89
261
|
if (total === -1 || total - used > 0) {
|
|
90
262
|
return { satisfied: true, reason: null, license };
|
|
91
263
|
}
|
|
@@ -98,7 +270,7 @@ export function evaluateLicenseRows(rows, profileName) {
|
|
|
98
270
|
|
|
99
271
|
/**
|
|
100
272
|
* Parse `sf org list --json` defensively into a flat org list + the default org
|
|
101
|
-
* (
|
|
273
|
+
* (mirrors isOrgConnected's try/catch tolerance). Collects
|
|
102
274
|
* every array under `result` (nonScratchOrgs, scratchOrgs, sandboxes, devHubs, …)
|
|
103
275
|
* so the picker sees all authenticated orgs; the default is the entry marked
|
|
104
276
|
* `isDefaultUsername`. Malformed / unexpected JSON ⇒ empty result (caller then
|
|
@@ -136,8 +308,8 @@ export function parseOrgList(jsonText) {
|
|
|
136
308
|
}
|
|
137
309
|
|
|
138
310
|
/**
|
|
139
|
-
* Multi-line warning for the non-TTY multi-bundle case
|
|
140
|
-
*
|
|
311
|
+
* Multi-line warning for the non-TTY multi-bundle case: there is nothing to
|
|
312
|
+
* interactively acknowledge, so keep the deterministic
|
|
141
313
|
* `all[0]` pick but never do it silently — name every bundle, the chosen one, and
|
|
142
314
|
* the `--ui-bundle-name` remedy.
|
|
143
315
|
*/
|
|
@@ -233,13 +405,12 @@ export function discoverAllUIBundleDirs(uiBundlesDir, uiBundleName) {
|
|
|
233
405
|
|
|
234
406
|
/**
|
|
235
407
|
* Resolve the single UI bundle to operate on, with the multi-bundle
|
|
236
|
-
* acknowledgment
|
|
237
|
-
* `--ui-bundle-name` was given:
|
|
408
|
+
* acknowledgment. When >1 bundle exists and no `--ui-bundle-name` was given:
|
|
238
409
|
* - TTY: present a single-select picker (all bundles, alphabetical all[0]
|
|
239
|
-
* preselected) — enter accepts all[0], arrows choose another
|
|
410
|
+
* preselected) — enter accepts all[0], arrows choose another.
|
|
240
411
|
* - non-TTY: keep the deterministic all[0] pick but emit the multi-line warning
|
|
241
|
-
*
|
|
242
|
-
* Explicit `--ui-bundle-name` short-circuits both (no prompt, no warning
|
|
412
|
+
* — never a silent pick.
|
|
413
|
+
* Explicit `--ui-bundle-name` short-circuits both (no prompt, no warning).
|
|
243
414
|
*/
|
|
244
415
|
export async function discoverUIBundleDir(uiBundlesDir, uiBundleName) {
|
|
245
416
|
const all = discoverAllUIBundleDirs(uiBundlesDir, uiBundleName);
|
|
@@ -265,7 +436,7 @@ export async function discoverUIBundleDir(uiBundlesDir, uiBundleName) {
|
|
|
265
436
|
* Interactive single-select: arrow keys navigate, enter confirms. Returns the
|
|
266
437
|
* chosen index. Falls through to `preselectedIndex` immediately when stdin is not
|
|
267
438
|
* a TTY. Built on the same raw-mode scaffolding as promptSteps (org-setup.mjs)
|
|
268
|
-
* but selects exactly one item
|
|
439
|
+
* but selects exactly one item.
|
|
269
440
|
*/
|
|
270
441
|
export function promptSelect(options, { prompt = 'Select an option:', preselectedIndex = 0 } = {}) {
|
|
271
442
|
if (!process.stdin.isTTY || options.length === 0) return Promise.resolve(preselectedIndex);
|
|
@@ -325,14 +496,13 @@ export function promptSelect(options, { prompt = 'Select an option:', preselecte
|
|
|
325
496
|
}
|
|
326
497
|
|
|
327
498
|
/**
|
|
328
|
-
* Resolve the target org
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
* and
|
|
332
|
-
*
|
|
333
|
-
* (AC-5); enter selects the default.
|
|
499
|
+
* Resolve the target org. When `--target-org` is supplied it is returned
|
|
500
|
+
* verbatim with NO `sf org list` call (byte-for-byte the scripted path).
|
|
501
|
+
* Otherwise discover authenticated orgs via `sf org list --json` and:
|
|
502
|
+
* - TTY: present a picker with the default org at the top and preselected;
|
|
503
|
+
* enter selects the default.
|
|
334
504
|
* - non-TTY: use the default org if resolvable, else exit 1 with a clear
|
|
335
|
-
* message
|
|
505
|
+
* message.
|
|
336
506
|
*/
|
|
337
507
|
export async function resolveTargetOrg(parsed) {
|
|
338
508
|
if (parsed.targetOrg) return parsed.targetOrg;
|
|
@@ -65,6 +65,11 @@ import {
|
|
|
65
65
|
resolveTargetOrg,
|
|
66
66
|
evaluateLicenseRows,
|
|
67
67
|
validateProfileNameForSoql,
|
|
68
|
+
validateSoqlName,
|
|
69
|
+
validateApiName,
|
|
70
|
+
escapeSoqlString,
|
|
71
|
+
parseApexInsertResults,
|
|
72
|
+
planApexBatches,
|
|
68
73
|
} from './org-setup-utils.mjs';
|
|
69
74
|
|
|
70
75
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -296,8 +301,8 @@ function parseArgs() {
|
|
|
296
301
|
else if (args[i] === '--skip-self-reg') flags.skipSelfReg = true;
|
|
297
302
|
else if (args[i] === '--skip-graphql') flags.skipGraphql = true;
|
|
298
303
|
else if (args[i] === '--skip-ui-bundle-build') flags.skipUIBundleBuild = true;
|
|
299
|
-
// --skip-login and --skip-dev are retired
|
|
300
|
-
//
|
|
304
|
+
// --skip-login and --skip-dev are retired: login is now an unconditional
|
|
305
|
+
// precondition and the dev step moved to `npm run dev:preview`.
|
|
301
306
|
// Accept them silently as no-ops so existing invocations don't hard-error.
|
|
302
307
|
else if (args[i] === '--skip-login' || args[i] === '--skip-dev') {
|
|
303
308
|
/* no-op (retired flag) */
|
|
@@ -344,7 +349,7 @@ Permset config (scripts/org-setup.config.json):
|
|
|
344
349
|
process.exit(0);
|
|
345
350
|
}
|
|
346
351
|
}
|
|
347
|
-
// NOTE: no hard-exit on a missing --target-org here
|
|
352
|
+
// NOTE: no hard-exit on a missing --target-org here. Resolution is
|
|
348
353
|
// deferred to resolveTargetOrg(), which either prompts (TTY) or falls back to
|
|
349
354
|
// the default org / exits with a clear message (non-TTY).
|
|
350
355
|
return { targetOrg, uiBundleName, permsetNamesExplicit, yes, ...flags };
|
|
@@ -352,8 +357,8 @@ Permset config (scripts/org-setup.config.json):
|
|
|
352
357
|
|
|
353
358
|
// Bundle discovery lives in org-setup-utils.mjs so org-setup-dev.mjs shares it verbatim.
|
|
354
359
|
// These thin wrappers bind the shared helpers to this script's UIBUNDLES_DIR and
|
|
355
|
-
// keep the existing call-site signatures. discoverUIBundleDir
|
|
356
|
-
// multi-bundle acknowledgment
|
|
360
|
+
// keep the existing call-site signatures. discoverUIBundleDir carries the
|
|
361
|
+
// multi-bundle acknowledgment (TTY picker / non-TTY warning).
|
|
357
362
|
function discoverAllUIBundleDirs(uiBundleName) {
|
|
358
363
|
return discoverAllUIBundleDirsIn(UIBUNDLES_DIR, uiBundleName);
|
|
359
364
|
}
|
|
@@ -420,7 +425,15 @@ function deriveSiteName() {
|
|
|
420
425
|
`cannot derive guest site: multiple network metadata files found in ${networksDir} (${files.join(', ')}); guestUser assignment requires exactly one`,
|
|
421
426
|
);
|
|
422
427
|
}
|
|
423
|
-
|
|
428
|
+
const siteName = files[0].replace(/\.network-meta\.xml$/, '');
|
|
429
|
+
// The site name is interpolated into SOQL (Network lookup, guest-user lookup),
|
|
430
|
+
// so validate it here — the single chokepoint both callers pass through. Its
|
|
431
|
+
// source is the metadata filename, not org-setup.config.json, so point the fix
|
|
432
|
+
// there.
|
|
433
|
+
validateSoqlName(siteName, 'site name', {
|
|
434
|
+
remediation: `the networks/<siteName>.network-meta.xml filename (currently "${files[0]}")`,
|
|
435
|
+
});
|
|
436
|
+
return siteName;
|
|
424
437
|
}
|
|
425
438
|
|
|
426
439
|
/**
|
|
@@ -603,8 +616,13 @@ function enableSelfRegistration(selfRegConfig, siteName, targetOrg) {
|
|
|
603
616
|
}
|
|
604
617
|
}
|
|
605
618
|
|
|
606
|
-
// 3. Create Account (idempotent)
|
|
607
|
-
|
|
619
|
+
// 3. Create Account (idempotent).
|
|
620
|
+
// accountName is a free-form display value (e.g. "O'Brien Rentals & Co.") that
|
|
621
|
+
// legitimately contains punctuation, so it is ESCAPED rather than whitelisted:
|
|
622
|
+
// escape it for the SOQL read here, and create it via Apex below
|
|
623
|
+
// (apexLiteral-escaped) rather than the `--values Name='...'` CLI arg, whose
|
|
624
|
+
// space-separated key=value parsing an escaped name cannot safely satisfy.
|
|
625
|
+
const acctQuery = `SELECT Id FROM Account WHERE Name = '${escapeSoqlString(accountName)}' LIMIT 1`;
|
|
608
626
|
const acctQueryResult = spawnSync('sf', [
|
|
609
627
|
'data', 'query',
|
|
610
628
|
'--query', acctQuery,
|
|
@@ -621,24 +639,34 @@ function enableSelfRegistration(selfRegConfig, siteName, targetOrg) {
|
|
|
621
639
|
if (accountId) {
|
|
622
640
|
console.log(` Account "${accountName}" already exists (${accountId}); skipping creation.`);
|
|
623
641
|
} else {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
642
|
+
// Create via Anonymous Apex (apexLiteral escapes the name) instead of
|
|
643
|
+
// `sf data create record --values`: the CLI parses --values as
|
|
644
|
+
// space-separated key=value pairs, so a name with a space, quote, or `&`
|
|
645
|
+
// either mis-parses or opens a field-injection surface. Apex string
|
|
646
|
+
// escaping closes that off and mirrors the NSR create just below.
|
|
647
|
+
const apex = [
|
|
648
|
+
`Account acct = new Account(Name = ${apexLiteral(accountName)});`,
|
|
649
|
+
`insert acct;`,
|
|
650
|
+
`System.debug('ACCOUNT_CREATED:' + acct.Id);`,
|
|
651
|
+
].join('\n');
|
|
652
|
+
const apexOut = withApexTempDir((writeApex) => {
|
|
653
|
+
const tmpApex = writeApex('account.apex', apex);
|
|
654
|
+
const apexResult = spawnSync('sf', [
|
|
655
|
+
'apex', 'run', '--target-org', targetOrg, '--file', tmpApex,
|
|
656
|
+
], { cwd: ROOT, stdio: 'pipe', shell: true, timeout: 60000 });
|
|
657
|
+
const out = apexResult.stdout?.toString() || '';
|
|
658
|
+
if (apexResult.status !== 0 && !out.includes('Compiled successfully')) {
|
|
659
|
+
process.stderr.write(apexResult.stderr?.toString() || out);
|
|
660
|
+
throw new StepError(`failed to create Account "${accountName}"`);
|
|
661
|
+
}
|
|
662
|
+
return out;
|
|
663
|
+
});
|
|
664
|
+
const acctMatch = apexOut.match(/ACCOUNT_CREATED:(\w+)/);
|
|
665
|
+
if (!acctMatch) {
|
|
640
666
|
throw new StepError('failed to parse Account creation result');
|
|
641
667
|
}
|
|
668
|
+
accountId = acctMatch[1];
|
|
669
|
+
console.log(` Created Account "${accountName}" (${accountId}).`);
|
|
642
670
|
}
|
|
643
671
|
|
|
644
672
|
// 4. Query Network Id
|
|
@@ -713,6 +741,7 @@ function enableSelfRegistration(selfRegConfig, siteName, targetOrg) {
|
|
|
713
741
|
* works correctly.
|
|
714
742
|
*/
|
|
715
743
|
function assignRoleToCurrentUser(roleName, targetOrg) {
|
|
744
|
+
validateSoqlName(roleName, 'role name');
|
|
716
745
|
const roleQuery = `SELECT Id FROM UserRole WHERE Name = '${roleName}'`;
|
|
717
746
|
const roleResult = spawnSync('sf', [
|
|
718
747
|
'data', 'query',
|
|
@@ -793,10 +822,30 @@ function assignRoleToCurrentUser(roleName, targetOrg) {
|
|
|
793
822
|
}
|
|
794
823
|
|
|
795
824
|
/**
|
|
796
|
-
* Query the org for
|
|
825
|
+
* Query the org for the guest user of the given site.
|
|
826
|
+
*
|
|
827
|
+
* Salesforce auto-creates a guest profile named "<Site> Profile" for each
|
|
828
|
+
* Experience Cloud site. The old `Profile.Name LIKE '%<siteName>%'` substring
|
|
829
|
+
* match collided across sites — e.g. site "shop" also matched "shop-admin"'s
|
|
830
|
+
* guest profile — and then blindly took records[0]. This uses an exact match on
|
|
831
|
+
* the profile name instead. SOQL string equality on a text field is
|
|
832
|
+
* case-insensitive by default (it wraps both sides in UPPER()), so a site whose
|
|
833
|
+
* label casing differs from the derived name still matches without explicit
|
|
834
|
+
* normalization. siteName is whitelist-validated at the deriveSiteName()
|
|
835
|
+
* chokepoint, so it is safe to interpolate here.
|
|
836
|
+
*
|
|
837
|
+
* Residual limitation: on a non-English org the auto-generated " Profile" suffix
|
|
838
|
+
* may be localized, in which case no row matches and the assignment soft-skips
|
|
839
|
+
* (returned null) with a clear message — the caller records that as a skip, not
|
|
840
|
+
* a crash.
|
|
841
|
+
*
|
|
842
|
+
* Returns the guest Username, or null (with a logged reason) when the query
|
|
843
|
+
* fails, no guest user exists, or the match is ambiguous.
|
|
797
844
|
*/
|
|
798
845
|
function resolveGuestUsername(siteName, targetOrg) {
|
|
799
|
-
const query =
|
|
846
|
+
const query =
|
|
847
|
+
`SELECT Username FROM User ` +
|
|
848
|
+
`WHERE Profile.Name = '${siteName} Profile' AND UserType = 'Guest'`;
|
|
800
849
|
const result = spawnSync('sf', [
|
|
801
850
|
'data', 'query',
|
|
802
851
|
'--query', query,
|
|
@@ -812,7 +861,15 @@ function resolveGuestUsername(siteName, targetOrg) {
|
|
|
812
861
|
const json = JSON.parse(result.stdout);
|
|
813
862
|
const records = json.result?.records;
|
|
814
863
|
if (!records || records.length === 0) {
|
|
815
|
-
console.error(` No guest user found for site "${siteName}".`);
|
|
864
|
+
console.error(` No guest user found for site "${siteName}" (looked for profile "${siteName} Profile").`);
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
if (records.length > 1) {
|
|
868
|
+
const names = records.map((r) => r.Username).join(', ');
|
|
869
|
+
console.error(
|
|
870
|
+
` Ambiguous guest user for site "${siteName}": ${records.length} guest users share ` +
|
|
871
|
+
`profile "${siteName} Profile" (${names}); refusing to guess.`,
|
|
872
|
+
);
|
|
816
873
|
return null;
|
|
817
874
|
}
|
|
818
875
|
return records[0].Username;
|
|
@@ -823,9 +880,9 @@ function resolveGuestUsername(siteName, targetOrg) {
|
|
|
823
880
|
}
|
|
824
881
|
|
|
825
882
|
/**
|
|
826
|
-
*
|
|
827
|
-
* belongs to and decide whether self-registration can proceed.
|
|
828
|
-
* is validate-and-fail
|
|
883
|
+
* License pre-check for self-registration. Query the UserLicense that the
|
|
884
|
+
* selfRegProfile belongs to and decide whether self-registration can proceed.
|
|
885
|
+
* The profile name is validate-and-fail: a SOQL-special character throws a config
|
|
829
886
|
* error rather than being escaped — profile names are developer-authored config,
|
|
830
887
|
* not user input, so a `'` / `\` / control char is a mistake to surface loudly.
|
|
831
888
|
*
|
|
@@ -887,7 +944,7 @@ function readProfileUserLicense(selfRegProfile) {
|
|
|
887
944
|
}
|
|
888
945
|
|
|
889
946
|
/**
|
|
890
|
-
* Deploy license pre-check
|
|
947
|
+
* Deploy license pre-check. `sf project deploy start` fails with a
|
|
891
948
|
* cryptic error when the org lacks the UserLicense the selfRegProfile's metadata
|
|
892
949
|
* declares. Resolve the required license NAME from the local profile source, then
|
|
893
950
|
* query the org's UserLicense by that name so a miss names the actual license the
|
|
@@ -902,10 +959,9 @@ function checkDeployLicense(selfRegConfig, targetOrg) {
|
|
|
902
959
|
// gate on; let deploy proceed and surface any real error on its own.
|
|
903
960
|
if (!licenseName) return { satisfied: true, reason: null };
|
|
904
961
|
|
|
905
|
-
const escaped = licenseName.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
906
962
|
const query =
|
|
907
963
|
`SELECT LicenseDefinitionKey, Name, Status, TotalLicenses, UsedLicenses ` +
|
|
908
|
-
`FROM UserLicense WHERE Name = '${
|
|
964
|
+
`FROM UserLicense WHERE Name = '${escapeSoqlString(licenseName)}'`;
|
|
909
965
|
const result = spawnSync('sf', [
|
|
910
966
|
'data', 'query',
|
|
911
967
|
'--query', query,
|
|
@@ -958,8 +1014,20 @@ function apexLiteral(value) {
|
|
|
958
1014
|
}
|
|
959
1015
|
|
|
960
1016
|
function buildApexInsert(sobject, records, refIds) {
|
|
1017
|
+
// The sobject type and every field key land as bare identifiers inside the
|
|
1018
|
+
// generated Apex (`new ${sobject}()`, `r.put('${key}', …)`), where they can't
|
|
1019
|
+
// be quoted-and-escaped like a value — so they get the strict API-name
|
|
1020
|
+
// whitelist. refIds are the record referenceIds echoed back through a JSON
|
|
1021
|
+
// marker; they carry no Apex-identifier role, so escaping (not whitelisting)
|
|
1022
|
+
// is the right treatment for their single-quoted literal. All three come from
|
|
1023
|
+
// app-shipped seed fixtures, so a violation is a fixture bug surfaced loudly.
|
|
1024
|
+
validateApiName(sobject, 'sobject type');
|
|
961
1025
|
const lines = [
|
|
962
1026
|
'Database.DMLOptions dmlOpts = new Database.DMLOptions();',
|
|
1027
|
+
// Intentional: bypass duplicate rules for seed-data import. These are
|
|
1028
|
+
// controlled fixtures the app ships, not user input, and duplicate-rule
|
|
1029
|
+
// blocks (plus matching-service timeouts the REST Sforce-Duplicate-Rule-Action
|
|
1030
|
+
// header can't override) would abort an otherwise-valid setup.
|
|
963
1031
|
'dmlOpts.DuplicateRuleHeader.allowSave = true;',
|
|
964
1032
|
`List<${sobject}> recs = new List<${sobject}>();`,
|
|
965
1033
|
];
|
|
@@ -967,17 +1035,28 @@ function buildApexInsert(sobject, records, refIds) {
|
|
|
967
1035
|
lines.push(`{ ${sobject} r = new ${sobject}();`);
|
|
968
1036
|
for (const [key, val] of Object.entries(rec)) {
|
|
969
1037
|
if (key === 'attributes') continue;
|
|
1038
|
+
validateApiName(key, `field API name on ${sobject}`);
|
|
970
1039
|
lines.push(`r.put('${key}', ${apexLiteral(val)});`);
|
|
971
1040
|
}
|
|
972
1041
|
lines.push('recs.add(r); }');
|
|
973
1042
|
}
|
|
974
1043
|
lines.push('Database.SaveResult[] results = Database.insert(recs, dmlOpts);');
|
|
975
|
-
const refArray = refIds.map((r) => `'${r}'`).join(',');
|
|
1044
|
+
const refArray = refIds.map((r) => `'${escapeSoqlString(r)}'`).join(',');
|
|
976
1045
|
lines.push(`String[] refs = new String[]{${refArray}};`);
|
|
1046
|
+
// Emit ONE machine-readable line instead of one System.debug per record: a
|
|
1047
|
+
// JSON array of {ref, id?|err?}. The Node side parses this with
|
|
1048
|
+
// parseApexInsertResults and treats a missing/unparseable marker as a hard
|
|
1049
|
+
// batch failure (a truncated debug line must not read as "0 errors"). Measured
|
|
1050
|
+
// batching keeps each batch — and thus this payload — well under the log line
|
|
1051
|
+
// size.
|
|
1052
|
+
lines.push('List<Object> setupOut = new List<Object>();');
|
|
977
1053
|
lines.push('for (Integer i = 0; i < results.size(); i++) {');
|
|
978
|
-
lines.push(
|
|
979
|
-
lines.push(
|
|
1054
|
+
lines.push(' Map<String,Object> m = new Map<String,Object>{ \'ref\' => refs[i] };');
|
|
1055
|
+
lines.push(' if (results[i].isSuccess()) { m.put(\'id\', results[i].getId()); }');
|
|
1056
|
+
lines.push(' else { m.put(\'err\', results[i].getErrors()[0].getMessage()); }');
|
|
1057
|
+
lines.push(' setupOut.add(m);');
|
|
980
1058
|
lines.push('}');
|
|
1059
|
+
lines.push("System.debug('SETUP_RESULT_JSON:' + JSON.serialize(setupOut));");
|
|
981
1060
|
return lines.join('\n');
|
|
982
1061
|
}
|
|
983
1062
|
|
|
@@ -1242,8 +1321,8 @@ async function main() {
|
|
|
1242
1321
|
skipUIBundleBuild: argSkipUIBundleBuild,
|
|
1243
1322
|
} = parsed;
|
|
1244
1323
|
|
|
1245
|
-
// Resolve the target org up front
|
|
1246
|
-
//
|
|
1324
|
+
// Resolve the target org up front: explicit --target-org is used verbatim;
|
|
1325
|
+
// otherwise prompt (TTY) or fall back to the default org / exit.
|
|
1247
1326
|
const targetOrg = await resolveTargetOrg(parsed);
|
|
1248
1327
|
|
|
1249
1328
|
// Per-target-org lock: acquired right after the start-of-run
|
|
@@ -1271,11 +1350,11 @@ async function main() {
|
|
|
1271
1350
|
const selfRegConfig = loadSelfRegConfig(config);
|
|
1272
1351
|
const hasSelfRegConfig = selfRegConfig !== null;
|
|
1273
1352
|
|
|
1274
|
-
// Validate the selfRegProfile name for SOQL-safety up front
|
|
1275
|
-
//
|
|
1276
|
-
//
|
|
1277
|
-
//
|
|
1278
|
-
// letting the
|
|
1353
|
+
// Validate the selfRegProfile name for SOQL-safety up front, alongside the
|
|
1354
|
+
// config validation and BEFORE any org mutation (login/deploy). A quote /
|
|
1355
|
+
// backslash / control char is a config mistake, not a runtime value to
|
|
1356
|
+
// escape — fail fast here with a clean, actionable message rather than
|
|
1357
|
+
// letting the license gate throw a raw stack trace deep in the run after deploy.
|
|
1279
1358
|
if (hasSelfRegConfig) {
|
|
1280
1359
|
try {
|
|
1281
1360
|
validateProfileNameForSoql(selfRegConfig.selfRegProfile);
|
|
@@ -1285,14 +1364,14 @@ async function main() {
|
|
|
1285
1364
|
}
|
|
1286
1365
|
}
|
|
1287
1366
|
|
|
1288
|
-
// failFast is a fixed, implementer-owned classification
|
|
1367
|
+
// failFast is a fixed, implementer-owned classification — NOT
|
|
1289
1368
|
// user-configurable. A fail-fast failure aborts the run immediately; a
|
|
1290
1369
|
// skippable failure is recorded and the run continues. The exit code is
|
|
1291
1370
|
// non-zero on any failure regardless of class.
|
|
1292
|
-
// Login is NOT in this picker
|
|
1293
|
-
//
|
|
1294
|
-
//
|
|
1295
|
-
//
|
|
1371
|
+
// Login is NOT in this picker: it is an unconditional precondition run before
|
|
1372
|
+
// deploy, not a toggleable step. The dev step is also gone: launching the dev
|
|
1373
|
+
// server moved to `npm run dev:preview`, so setup terminates cleanly after
|
|
1374
|
+
// graphql.
|
|
1296
1375
|
const stepDefs = [
|
|
1297
1376
|
{ key: 'uiBundleBuild', label: 'UI Bundle Build — npm install + build (pre-deploy)', enabled: !argSkipUIBundleBuild, available: true, failFast: true },
|
|
1298
1377
|
{ key: 'deploy', label: 'Deploy — sf project deploy start', enabled: !argSkipDeploy, available: true, failFast: true },
|
|
@@ -1333,7 +1412,7 @@ async function main() {
|
|
|
1333
1412
|
!skipUIBundleBuild
|
|
1334
1413
|
);
|
|
1335
1414
|
|
|
1336
|
-
// Login is an unconditional precondition
|
|
1415
|
+
// Login is an unconditional precondition: it is not part of
|
|
1337
1416
|
// the step picker and cannot be skipped. It still no-ops when the org is already
|
|
1338
1417
|
// connected, and stays fail-fast — a failed browser login aborts before deploy.
|
|
1339
1418
|
const loginStep = { key: 'login', label: 'Login — org authentication', failFast: true };
|
|
@@ -1389,7 +1468,7 @@ async function main() {
|
|
|
1389
1468
|
const deployStep = stepDefs.find((s) => s.key === 'deploy');
|
|
1390
1469
|
if (!skipDeploy) {
|
|
1391
1470
|
await runStep(deployStep, targetOrg, () => {
|
|
1392
|
-
// License pre-check
|
|
1471
|
+
// License pre-check: deploy fails with a cryptic error when the
|
|
1393
1472
|
// org lacks the UserLicense the selfRegProfile's metadata requires. Verify a
|
|
1394
1473
|
// seat is available BEFORE `sf project deploy start` so the run aborts with a
|
|
1395
1474
|
// clean message NAMING the missing license. The required license is read from
|
|
@@ -1510,7 +1589,7 @@ async function main() {
|
|
|
1510
1589
|
|
|
1511
1590
|
const selfRegStep = stepDefs.find((s) => s.key === 'selfReg');
|
|
1512
1591
|
if (!skipSelfReg) {
|
|
1513
|
-
//
|
|
1592
|
+
// License pre-check: self-registration requires a seat on the
|
|
1514
1593
|
// UserLicense the selfRegProfile belongs to. Verify it BEFORE running the step
|
|
1515
1594
|
// — recordSkipped is only reachable here, ahead of runStep (mirroring the
|
|
1516
1595
|
// not-selected branch); once inside a step body an unmet precondition could
|
|
@@ -1563,6 +1642,10 @@ async function main() {
|
|
|
1563
1642
|
// always removed in finally, even if a batch throws.
|
|
1564
1643
|
withApexTempDir((writeApex) => {
|
|
1565
1644
|
for (const sobject of sobjectsReversed) {
|
|
1645
|
+
// Bare identifier inside a dynamic-SOQL string literal — whitelist it (the
|
|
1646
|
+
// same guard buildApexInsert applies) so a malformed data-plan sobject
|
|
1647
|
+
// can't alter the delete query.
|
|
1648
|
+
validateApiName(sobject, 'sobject type');
|
|
1566
1649
|
const apexCode = [
|
|
1567
1650
|
'try {',
|
|
1568
1651
|
` List<SObject> recs = Database.query('SELECT Id FROM ${sobject} LIMIT 10000');`,
|
|
@@ -1587,7 +1670,7 @@ async function main() {
|
|
|
1587
1670
|
// Import via Anonymous Apex with Database.DMLOptions.duplicateRuleHeader.allowSave = true.
|
|
1588
1671
|
// This bypasses both duplicate-rule blocks AND matching-service timeouts that the REST
|
|
1589
1672
|
// API headers (Sforce-Duplicate-Rule-Action) cannot override.
|
|
1590
|
-
console.log('\n--- Data import
|
|
1673
|
+
console.log('\n--- Data import (Apex) ---');
|
|
1591
1674
|
const refMap = new Map();
|
|
1592
1675
|
const APEX_CHAR_LIMIT = 25000;
|
|
1593
1676
|
const APEX_MAX_BATCH = 200;
|
|
@@ -1612,14 +1695,37 @@ async function main() {
|
|
|
1612
1695
|
}
|
|
1613
1696
|
}
|
|
1614
1697
|
|
|
1698
|
+
// Measured batching: size each record's rendered Apex (using
|
|
1699
|
+
// the same apexLiteral the insert uses) and pack batches under the char
|
|
1700
|
+
// limit, instead of the old estCharsPerRec = 40 + fields*55 guess that
|
|
1701
|
+
// under-counted long text fields and could overflow the anon-Apex limit.
|
|
1702
|
+
// Overhead is measured from the real builder with zero records so the
|
|
1703
|
+
// fixed boilerplate can't drift from buildApexInsert. Each record also
|
|
1704
|
+
// grows the per-batch `String[] refs = new String[]{'…',…}` line, so its
|
|
1705
|
+
// rendered refId literal is counted here too (mirroring the same
|
|
1706
|
+
// referenceId/_idx fallback the batch emit uses) — otherwise sizing
|
|
1707
|
+
// under-counts and a packed batch could edge past APEX_CHAR_LIMIT.
|
|
1708
|
+
const overhead = buildApexInsert(entry.sobject, [], []).length;
|
|
1709
|
+
const recordSizes = records.map((rec, i) => {
|
|
1710
|
+
let size = `{ ${entry.sobject} r = new ${entry.sobject}();\n`.length + 'recs.add(r); }\n'.length;
|
|
1711
|
+
for (const [key, val] of Object.entries(rec)) {
|
|
1712
|
+
if (key === 'attributes') continue;
|
|
1713
|
+
size += `r.put('${key}', ${apexLiteral(val)});\n`.length;
|
|
1714
|
+
}
|
|
1715
|
+
const refId = rec.attributes?.referenceId || `_idx${i}`;
|
|
1716
|
+
size += `'${escapeSoqlString(refId)}',`.length;
|
|
1717
|
+
return size;
|
|
1718
|
+
});
|
|
1719
|
+
const batches = planApexBatches(recordSizes, {
|
|
1720
|
+
charLimit: APEX_CHAR_LIMIT,
|
|
1721
|
+
maxBatch: APEX_MAX_BATCH,
|
|
1722
|
+
overhead,
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1615
1725
|
let imported = 0;
|
|
1616
|
-
const
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
const batchSize = Math.min(APEX_MAX_BATCH, Math.max(5, Math.floor(APEX_CHAR_LIMIT / estCharsPerRec)));
|
|
1620
|
-
for (let i = 0; i < records.length; i += batchSize) {
|
|
1621
|
-
const batch = records.slice(i, i + batchSize);
|
|
1622
|
-
const refIds = batch.map((r) => r.attributes?.referenceId || `_idx${i}`);
|
|
1726
|
+
for (const { start, count } of batches) {
|
|
1727
|
+
const batch = records.slice(start, start + count);
|
|
1728
|
+
const refIds = batch.map((r, j) => r.attributes?.referenceId || `_idx${start + j}`);
|
|
1623
1729
|
const apex = buildApexInsert(entry.sobject, batch, refIds);
|
|
1624
1730
|
const tmpApex = writeApex('data.apex', apex);
|
|
1625
1731
|
const apexResult = spawnSync(
|
|
@@ -1633,19 +1739,24 @@ async function main() {
|
|
|
1633
1739
|
process.stderr.write(apexErr || apexOut);
|
|
1634
1740
|
throw new StepError(`${entry.sobject}: apex execution failed`);
|
|
1635
1741
|
}
|
|
1636
|
-
const
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1742
|
+
const parsed = parseApexInsertResults(apexOut);
|
|
1743
|
+
if (!parsed.ok) {
|
|
1744
|
+
// A missing/truncated result marker must fail loudly — never read as
|
|
1745
|
+
// "0 errors" and let a partial import look successful.
|
|
1746
|
+
process.stderr.write(apexErr || apexOut);
|
|
1747
|
+
throw new StepError(`data import (${entry.sobject}) — ${parsed.error}`);
|
|
1748
|
+
}
|
|
1749
|
+
if (parsed.errors.length) {
|
|
1750
|
+
for (const e of parsed.errors.slice(0, 5)) {
|
|
1751
|
+
console.error(` ${e.ref}: ${e.message.trim()}`);
|
|
1641
1752
|
}
|
|
1642
|
-
if (
|
|
1643
|
-
throw new StepError(`data import
|
|
1753
|
+
if (parsed.errors.length > 5) console.error(` ... and ${parsed.errors.length - 5} more`);
|
|
1754
|
+
throw new StepError(`data import (${entry.sobject}) — ${parsed.errors.length} record error(s)`);
|
|
1644
1755
|
}
|
|
1645
1756
|
if (entry.saveRefs) {
|
|
1646
|
-
for (const
|
|
1757
|
+
for (const s of parsed.successes) refMap.set(s.ref, s.id);
|
|
1647
1758
|
}
|
|
1648
|
-
imported +=
|
|
1759
|
+
imported += parsed.successes.length;
|
|
1649
1760
|
}
|
|
1650
1761
|
console.log(` ${entry.sobject}: imported ${imported} records`);
|
|
1651
1762
|
}
|
|
@@ -1687,7 +1798,7 @@ async function main() {
|
|
|
1687
1798
|
recordOk(uiBundleBuildStep);
|
|
1688
1799
|
}
|
|
1689
1800
|
|
|
1690
|
-
// Setup terminates cleanly here
|
|
1801
|
+
// Setup terminates cleanly here. Launching the dev server is
|
|
1691
1802
|
// no longer part of setup — run `npm run dev:preview` (scripts/org-setup-dev.mjs) for that.
|
|
1692
1803
|
printSummary(targetOrg);
|
|
1693
1804
|
if (finalExitCode() === 0) {
|
package/package.json
CHANGED