@firedrill-tools/salesforce 0.1.1

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 (41) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +156 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/api-limit-exceeded.scenario.json +11 -0
  5. package/firedrill/baseline.scenario.json +2213 -0
  6. package/firedrill/conformance.suite.json +23 -0
  7. package/firedrill/row-locked.scenario.json +11 -0
  8. package/firedrill/salesforce-api-limit-exceeded.drill.json +336 -0
  9. package/firedrill/salesforce-collections-composite.drill.json +277 -0
  10. package/firedrill/salesforce-denied.drill.json +74 -0
  11. package/firedrill/salesforce-fresh-install.drill.json +86 -0
  12. package/firedrill/salesforce-inactive-user.drill.json +43 -0
  13. package/firedrill/salesforce-invalid-session.drill.json +336 -0
  14. package/firedrill/salesforce-large-responses.drill.json +138 -0
  15. package/firedrill/salesforce-mcp-aliases.drill.json +156 -0
  16. package/firedrill/salesforce-profile-permissions.drill.json +241 -0
  17. package/firedrill/salesforce-read-only.drill.json +154 -0
  18. package/firedrill/salesforce-rest-flow.drill.json +714 -0
  19. package/firedrill/salesforce-row-locked.drill.json +227 -0
  20. package/firedrill/salesforce-sharing.drill.json +201 -0
  21. package/firedrill/salesforce-soql.drill.json +139 -0
  22. package/firedrill/salesforce-tight-limits.drill.json +336 -0
  23. package/firedrill/salesforce-write-committed-lost.drill.json +176 -0
  24. package/firedrill/tight-limits.scenario.json +41 -0
  25. package/firedrill/tools/salesforce/behavior.mjs +637 -0
  26. package/firedrill/tools/salesforce/lib/bytes.mjs +56 -0
  27. package/firedrill/tools/salesforce/lib/ids.mjs +68 -0
  28. package/firedrill/tools/salesforce/lib/match.mjs +352 -0
  29. package/firedrill/tools/salesforce/lib/records.mjs +506 -0
  30. package/firedrill/tools/salesforce/lib/schema.mjs +429 -0
  31. package/firedrill/tools/salesforce/lib/search.mjs +92 -0
  32. package/firedrill/tools/salesforce/lib/soql.mjs +1119 -0
  33. package/firedrill/tools/salesforce/lib/state.mjs +378 -0
  34. package/firedrill/tools/salesforce/lib/wire.mjs +109 -0
  35. package/firedrill/tools/salesforce/salesforce.tool.json +3939 -0
  36. package/firedrill/world.json +2705 -0
  37. package/firedrill/write-committed-lost.scenario.json +11 -0
  38. package/firedrill.json +5 -0
  39. package/package.json +64 -0
  40. package/starter.json +2212 -0
  41. package/test/conformance.mjs +1122 -0
@@ -0,0 +1,637 @@
1
+ // Synthetic Salesforce org. Every operation computes from context.state: record ids come from the
2
+ // `meta/counters` row, timestamps from the virtual clock, visibility from the calling user's profile
3
+ // and the org-wide defaults. Nothing here contacts Salesforce; no e-mail, Chatter post, workflow or
4
+ // trigger ever runs.
5
+ import { normalizeId, typeOfId } from "./lib/ids.mjs";
6
+ import { NOT_FOUND_MESSAGE, createRecord, deleteRecord, findByExternalId, recentItems, recordUrl, renderPaths, renderRecord, requireExternalIdField, requireId, requireReadableType, requireRecord, requireWritableType, resolvePath, updateRecord, upsertRecord } from "./lib/records.mjs";
7
+ import { ALL_TYPES, DEFAULT_VERSION, canonicalType, describeSObject, fieldByName, isRecordType, isSupportedVersion, sobjectSummary, versionsTable } from "./lib/schema.mjs";
8
+ import { parameterizedSearch } from "./lib/search.mjs";
9
+ import { clampBatchSize, decodeLocator, executeQuery, parseQuery, tooLargeMessage } from "./lib/soql.mjs";
10
+ import { allRows, canRead, clip, fieldsOf, flushEvents, getRow, isDeleted, isRecordError, mark, openSession, permissions, raise, recordError, rollback } from "./lib/state.mjs";
11
+ import { RESPONSE_BYTE_BUDGET, byteBudget, jsonBytes } from "./lib/bytes.mjs";
12
+ import { batchSizeHeader, boolOrRaw, defined, jsonBody, list, operationInput, responseHeaders, salesforceError, str, stripLimitInfo } from "./lib/wire.mjs";
13
+
14
+ const MAX_COLLECTION = 200;
15
+ const MAX_RETRIEVE_IDS = 2000;
16
+ const MAX_SUBREQUESTS = 25;
17
+ const MAX_QUERY_SUBREQUESTS = 5;
18
+ const ROLLED_BACK = "Record rolled back because not all records were valid and the request was using AllOrNone header";
19
+ const HALTED = "The transaction was rolled back since another operation in the same transaction failed.";
20
+ const JSON_TYPE = "application/json;charset=UTF-8";
21
+
22
+ // ---------------------------------------------------------------------------------------------
23
+ // Operation plumbing
24
+ // ---------------------------------------------------------------------------------------------
25
+
26
+ /** Open the session, run, flush events, attach the limit carrier; RecordErrors become declared failures. */
27
+ function operation(handler) {
28
+ return (input, context) => {
29
+ const session = openSession(context);
30
+ let value;
31
+ try {
32
+ value = handler(session, input);
33
+ } catch (error) {
34
+ return raise(session, error);
35
+ }
36
+ flushEvents(session);
37
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) return { ...value, _limitInfo: session.limitInfo };
38
+ return value;
39
+ };
40
+ }
41
+
42
+ function requireVersion(session, input) {
43
+ const version = input.version === undefined ? DEFAULT_VERSION : input.version;
44
+ if (!isSupportedVersion(version)) throw recordError("NOT_FOUND", NOT_FOUND_MESSAGE);
45
+ return version;
46
+ }
47
+
48
+ function isPlainObject(value) {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value);
50
+ }
51
+
52
+ function parserError(message) {
53
+ return recordError("JSON_PARSER_ERROR", message);
54
+ }
55
+
56
+ function itemError(error) {
57
+ if (!isRecordError(error)) throw error;
58
+ return { statusCode: error.statusCode, message: error.message, fields: error.fields };
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------------------------
62
+ // Handshake, identity, limits, describe
63
+ // ---------------------------------------------------------------------------------------------
64
+
65
+ function userinfo(session, input) {
66
+ if (input.defaultDevHub === true) throw recordError("NOT_FOUND", "No Dev Hub org is configured for this Tool");
67
+ const org = session.org;
68
+ const user = session.user;
69
+ const base = String(org.instanceUrl ?? "").replace(/\/$/, "");
70
+ const modified = Math.floor(Date.parse(String(user.LastModifiedDate ?? "1970-01-01T00:00:00.000+0000").replace(/([+-]\d{2})(\d{2})$/, "$1:$2")) / 1000);
71
+ return {
72
+ sub: `${base}/id/${org.organizationId}/${user.Id}`,
73
+ user_id: user.Id,
74
+ organization_id: org.organizationId,
75
+ preferred_username: user.Username,
76
+ nickname: user.Alias,
77
+ name: user.Name,
78
+ email: user.Email,
79
+ email_verified: true,
80
+ given_name: user.FirstName ?? "",
81
+ family_name: user.LastName,
82
+ zoneinfo: user.TimeZoneSidKey,
83
+ photos: { picture: `${base}/profilephoto/005/F`, thumbnail: `${base}/profilephoto/005/T` },
84
+ profile: `${base}/${user.Id}`,
85
+ picture: `${base}/profilephoto/005/F`,
86
+ address: { country: String(user.LocaleSidKey ?? "en_US").split("_")[1] ?? "US" },
87
+ urls: {
88
+ enterprise: `${base}/services/Soap/c/{version}/${org.organizationId}`,
89
+ metadata: `${base}/services/Soap/m/{version}/${org.organizationId}`,
90
+ partner: `${base}/services/Soap/u/{version}/${org.organizationId}`,
91
+ rest: `${base}/services/data/v{version}/`,
92
+ sobjects: `${base}/services/data/v{version}/sobjects/`,
93
+ search: `${base}/services/data/v{version}/search/`,
94
+ query: `${base}/services/data/v{version}/query/`,
95
+ recent: `${base}/services/data/v{version}/recent/`,
96
+ profile: `${base}/${user.Id}`,
97
+ custom_domain: base,
98
+ },
99
+ active: user.IsActive === true,
100
+ user_type: user.UserType ?? "Standard",
101
+ language: user.LanguageLocaleKey ?? "en_US",
102
+ locale: user.LocaleSidKey ?? "en_US",
103
+ utcOffset: 0,
104
+ updated_at: new Date(modified * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"),
105
+ is_app_installed: false,
106
+ };
107
+ }
108
+
109
+ function limits(session, input) {
110
+ requireVersion(session, input);
111
+ const api = session.org.dailyApiRequests ?? { max: 0, used: 0 };
112
+ const storage = session.org.dataStorageMB ?? { max: 0, used: 0 };
113
+ const pair = (max, used = 0) => ({ Max: max, Remaining: Math.max(0, max - used) });
114
+ return {
115
+ DailyApiRequests: pair(api.max, api.used),
116
+ DailyBulkApiBatches: pair(15000),
117
+ DailyBulkV2QueryJobs: pair(10000),
118
+ DailyAsyncApexExecutions: pair(250000),
119
+ DailyStreamingApiEvents: pair(50000),
120
+ DataStorageMB: pair(storage.max, storage.used),
121
+ FileStorageMB: pair(Math.max(storage.max, 1) * 10, 0),
122
+ HourlyODataCallout: pair(20000),
123
+ ConcurrentAsyncGetReportInstances: pair(200),
124
+ MassEmail: pair(5000),
125
+ SingleEmail: pair(5000),
126
+ PermissionSets: { ...pair(1500), CreateCustom: pair(1000) },
127
+ };
128
+ }
129
+
130
+ function readableTypes(session) {
131
+ return ALL_TYPES.filter((type) => permissions(session, type).read).sort();
132
+ }
133
+
134
+ function sobjectsList(session, input) {
135
+ const version = requireVersion(session, input);
136
+ return { encoding: "UTF-8", maxBatchSize: MAX_COLLECTION, sobjects: readableTypes(session).map((type) => sobjectSummary(type, version, permissions(session, type))) };
137
+ }
138
+
139
+ function describableType(session, input) {
140
+ const type = canonicalType(input.sobjectType);
141
+ if (type === null || !permissions(session, type).read) throw recordError("NOT_FOUND", NOT_FOUND_MESSAGE);
142
+ return type;
143
+ }
144
+
145
+ function basicInfo(session, input) {
146
+ const version = requireVersion(session, input);
147
+ const type = describableType(session, input);
148
+ return { objectDescribe: sobjectSummary(type, version, permissions(session, type)), recentItems: recentItems(session, type, version) };
149
+ }
150
+
151
+ function describe(session, input) {
152
+ const version = requireVersion(session, input);
153
+ const type = describableType(session, input);
154
+ return describeSObject(type, version, permissions(session, type), fieldsOf(session, type));
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------------------------
158
+ // sObject rows
159
+ // ---------------------------------------------------------------------------------------------
160
+
161
+ function create(session, input) {
162
+ requireVersion(session, input);
163
+ const type = requireWritableType(session, input.sobjectType, "create");
164
+ const row = createRecord(session, type, input.record);
165
+ return { id: row.Id, success: true, errors: [] };
166
+ }
167
+
168
+ function retrieve(session, input) {
169
+ const version = requireVersion(session, input);
170
+ const type = requireReadableType(session, input.sobjectType);
171
+ let row;
172
+ if (input.externalIdField !== undefined || input.value !== undefined) {
173
+ const field = requireExternalIdField(session, type, input.externalIdField);
174
+ const matches = findByExternalId(session, type, field, input.value ?? "");
175
+ row = matches[0] ?? null;
176
+ if (row === null || !canRead(session, type, row)) throw recordError("NOT_FOUND", NOT_FOUND_MESSAGE);
177
+ } else {
178
+ if (input.id === undefined) throw recordError("MALFORMED_ID", "malformed id (missing)");
179
+ row = requireRecord(session, type, input.id);
180
+ }
181
+ if (Array.isArray(input.fields)) {
182
+ const paths = input.fields.map((path) => resolvePath(session, type, path, "sobject"));
183
+ const rendered = renderPaths(session, type, row, version, paths);
184
+ if (rendered.Id === undefined) rendered.Id = row.Id;
185
+ return rendered;
186
+ }
187
+ return renderRecord(session, type, row, version);
188
+ }
189
+
190
+ function update(session, input) {
191
+ requireVersion(session, input);
192
+ const type = requireWritableType(session, input.sobjectType, "update");
193
+ updateRecord(session, type, input.id, input.record);
194
+ return {};
195
+ }
196
+
197
+ function remove(session, input) {
198
+ requireVersion(session, input);
199
+ const type = requireWritableType(session, input.sobjectType, "delete");
200
+ deleteRecord(session, type, input.id);
201
+ return {};
202
+ }
203
+
204
+ function upsert(session, input) {
205
+ requireVersion(session, input);
206
+ const type = requireWritableType(session, input.sobjectType, "upsert");
207
+ const result = upsertRecord(session, type, input.externalIdField, input.value, input.record);
208
+ return { id: result.id, success: true, errors: [], created: result.created };
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------------------------
212
+ // SOQL and search
213
+ // ---------------------------------------------------------------------------------------------
214
+
215
+ function requireAlias(session, value) {
216
+ if (value === undefined) return;
217
+ const wanted = String(value).toLowerCase();
218
+ const hit = allRows(session, "users").some((user) => user.IsActive === true && (String(user.Username).toLowerCase() === wanted || String(user.Alias ?? "").toLowerCase() === wanted));
219
+ if (!hit) throw recordError("NOT_FOUND", `No authorization information found for ${clip(value)}.`);
220
+ }
221
+
222
+ /** `byteLimit` is internal (composite passes the bytes left in its response); callers cannot set it. */
223
+ function query(session, input, byteLimit = RESPONSE_BYTE_BUDGET) {
224
+ const version = requireVersion(session, input);
225
+ const spellings = [input.q, input.query].filter((value) => typeof value === "string");
226
+ if (spellings.length !== 1) throw recordError("MALFORMED_QUERY", "exactly one of 'q' or 'query' must carry the SOQL statement");
227
+ if (input.useToolingApi === true) throw recordError("INVALID_TYPE", "Tooling API objects are not simulated by this Tool (useToolingApi must be false)");
228
+ requireAlias(session, input.usernameOrAlias);
229
+ const text = spellings[0];
230
+ if (text.trim().length === 0) throw recordError("MALFORMED_QUERY", "the q parameter is required");
231
+ // The HTTP layer decodes undecodable percent-encoding (e.g. %E0%A4%A) to U+FFFD and codecs never see the
232
+ // raw bytes, so a statement carrying U+FFFD is rejected rather than run with corrupted literals.
233
+ if (text.includes("\uFFFD")) throw recordError("MALFORMED_QUERY", "the q parameter is not valid percent-encoded UTF-8");
234
+ const parsed = parseQuery(text);
235
+ return executeQuery(session, parsed, { text, version, includeDeleted: input.includeDeleted === true, batchSize: clampBatchSize(input.batchSize), offset: 0, byteLimit });
236
+ }
237
+
238
+ function queryMore(session, input) {
239
+ const version = requireVersion(session, input);
240
+ const locator = decodeLocator(input.locator);
241
+ if (locator === null || locator.userId !== session.user.Id) throw recordError("INVALID_QUERY_LOCATOR", "invalid query locator");
242
+ const parsed = parseQuery(locator.q);
243
+ return executeQuery(session, parsed, { text: locator.q, version, includeDeleted: locator.includeDeleted, batchSize: locator.batchSize, offset: locator.offset });
244
+ }
245
+
246
+ function search(session, input) {
247
+ const version = requireVersion(session, input);
248
+ return parameterizedSearch(session, input, version);
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------------------------
252
+ // sObject Collections
253
+ // ---------------------------------------------------------------------------------------------
254
+
255
+ function requireRecordsEnvelope(input) {
256
+ if (!Array.isArray(input.records) || input.records.length === 0) throw parserError("records must be a non-empty array of sObject records");
257
+ if (input.records.length > MAX_COLLECTION) throw recordError("LIMIT_EXCEEDED", `Cannot process more than ${MAX_COLLECTION} records at once`);
258
+ for (const record of input.records) {
259
+ if (!isPlainObject(record) || !isPlainObject(record.attributes) || typeof record.attributes.type !== "string") {
260
+ throw parserError("Each record must carry attributes.type naming its sObject");
261
+ }
262
+ }
263
+ return input.records;
264
+ }
265
+
266
+ function bodyWithout(record, keys) {
267
+ return Object.fromEntries(Object.entries(record).filter(([key]) => !keys.includes(key) && key !== "attributes"));
268
+ }
269
+
270
+ /** Run one item per record; under allOrNone a single failure rolls everything back. */
271
+ function envelope(session, items, allOrNone, run) {
272
+ const start = mark(session);
273
+ const results = items.map((item) => {
274
+ try {
275
+ return run(item);
276
+ } catch (error) {
277
+ return { id: null, success: false, errors: [itemError(error)] };
278
+ }
279
+ });
280
+ if (allOrNone === true && results.some((result) => result.success === false)) {
281
+ rollback(session, start);
282
+ return results.map((result) => (result.success ? { id: result.id, success: false, errors: [{ statusCode: "ALL_OR_NONE_OPERATION_ROLLED_BACK", message: ROLLED_BACK, fields: [] }] } : result));
283
+ }
284
+ return results;
285
+ }
286
+
287
+ function collectionsCreate(session, input) {
288
+ requireVersion(session, input);
289
+ const records = requireRecordsEnvelope(input);
290
+ return envelope(session, records, input.allOrNone, (record) => {
291
+ const type = requireWritableType(session, record.attributes.type, "create");
292
+ const row = createRecord(session, type, bodyWithout(record, []));
293
+ return { id: row.Id, success: true, errors: [] };
294
+ });
295
+ }
296
+
297
+ function collectionsRetrieve(session, input) {
298
+ const version = requireVersion(session, input);
299
+ const type = requireReadableType(session, input.sobjectType);
300
+ if (!Array.isArray(input.ids) || input.ids.length === 0) throw parserError("ids must be a non-empty array of record ids");
301
+ if (input.ids.length > MAX_RETRIEVE_IDS) throw recordError("LIMIT_EXCEEDED", `Cannot retrieve more than ${MAX_RETRIEVE_IDS} records at once`);
302
+ if (!Array.isArray(input.fields) || input.fields.length === 0) throw parserError("fields must be a non-empty array of field names");
303
+ const paths = input.fields.map((path) => resolvePath(session, type, path, "sobject"));
304
+ // Each entry is measured before it is admitted; a response past the byte budget fails LIMIT_EXCEEDED.
305
+ const budget = byteBudget(RESPONSE_BYTE_BUDGET, 2);
306
+ return input.ids.map((raw) => {
307
+ const id = requireId(raw);
308
+ const row = typeOfId(id) === type ? getRow(session, type, id) : null;
309
+ let rendered = null;
310
+ if (row !== null && !isDeleted(row) && canRead(session, type, row)) {
311
+ rendered = renderPaths(session, type, row, version, paths);
312
+ if (rendered.Id === undefined) rendered.Id = row.Id;
313
+ }
314
+ if (!budget.admit(rendered)) throw recordError("LIMIT_EXCEEDED", tooLargeMessage(RESPONSE_BYTE_BUDGET));
315
+ return rendered;
316
+ });
317
+ }
318
+
319
+ function collectionsUpdate(session, input) {
320
+ requireVersion(session, input);
321
+ const records = requireRecordsEnvelope(input);
322
+ return envelope(session, records, input.allOrNone, (record) => {
323
+ const type = requireWritableType(session, record.attributes.type, "update");
324
+ const idKey = Object.keys(record).find((key) => key.toLowerCase() === "id");
325
+ if (idKey === undefined || typeof record[idKey] !== "string") throw recordError("MISSING_ARGUMENT", "Id not specified in an update call", ["Id"]);
326
+ const { row } = updateRecord(session, type, record[idKey], bodyWithout(record, [idKey]));
327
+ return { id: row.Id, success: true, errors: [] };
328
+ });
329
+ }
330
+
331
+ function collectionsDelete(session, input) {
332
+ requireVersion(session, input);
333
+ if (!Array.isArray(input.ids) || input.ids.length === 0) throw parserError("ids must be a non-empty list of record ids");
334
+ if (input.ids.length > MAX_COLLECTION) throw recordError("LIMIT_EXCEEDED", `Cannot process more than ${MAX_COLLECTION} records at once`);
335
+ return envelope(session, input.ids, input.allOrNone, (raw) => {
336
+ const id = requireId(raw);
337
+ const type = typeOfId(id);
338
+ if (type === null) throw recordError("MALFORMED_ID", `malformed id ${clip(id)}`);
339
+ requireWritableType(session, type, "delete");
340
+ deleteRecord(session, type, id);
341
+ return { id, success: true, errors: [] };
342
+ });
343
+ }
344
+
345
+ function collectionsUpsert(session, input) {
346
+ requireVersion(session, input);
347
+ const type = requireWritableType(session, input.sobjectType, "upsert");
348
+ const field = fieldByName(fieldsOf(session, type), input.externalIdField);
349
+ if (field === null) throw recordError("INVALID_FIELD", `No such column '${clip(input.externalIdField)}' on sobject of type ${type}`, [String(input.externalIdField)]);
350
+ if (field.name !== "Id" && !field.externalId) throw recordError("INVALID_FIELD", `${field.name} is not an external ID field on ${type}`, [field.name]);
351
+ const records = requireRecordsEnvelope(input);
352
+ return envelope(session, records, input.allOrNone, (record) => {
353
+ if (canonicalType(record.attributes.type) !== type) throw recordError("INVALID_TYPE", `sObject type '${clip(record.attributes.type)}' does not match the collection type ${type}`);
354
+ const key = Object.keys(record).find((candidate) => candidate.toLowerCase() === field.name.toLowerCase());
355
+ if (key === undefined || record[key] === null || record[key] === undefined) throw recordError("MISSING_ARGUMENT", `External ID field ${field.name} not specified`, [field.name]);
356
+ const result = upsertRecord(session, type, field.name, String(record[key]), bodyWithout(record, [key]));
357
+ return { id: result.id, success: true, errors: [], created: result.created };
358
+ });
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------------------------
362
+ // Composite
363
+ // ---------------------------------------------------------------------------------------------
364
+
365
+ const REFERENCE = /@\{([A-Za-z0-9_]+)((?:\.[A-Za-z0-9_]+|\[\d+\])*)\}/g;
366
+
367
+ function referenceError(text) {
368
+ return { statusCode: "INVALID_INPUT", message: `Invalid reference specified. No value for ${clip(text)} found in ${clip(text.replace(/^@\{|\}$/g, "").split(/[.[]/)[0])}`, fields: [] };
369
+ }
370
+
371
+ function lookupReference(refs, name, path) {
372
+ const body = refs.get(name);
373
+ if (body === undefined) return undefined;
374
+ let current = body;
375
+ const segments = path.match(/\.[A-Za-z0-9_]+|\[\d+\]/g) ?? [];
376
+ for (const segment of segments) {
377
+ if (current === null || typeof current !== "object") return undefined;
378
+ if (segment.startsWith("[")) current = current[Number(segment.slice(1, -1))];
379
+ else {
380
+ const key = segment.slice(1);
381
+ const actual = Object.keys(current).find((candidate) => candidate === key) ?? Object.keys(current).find((candidate) => candidate.toLowerCase() === key.toLowerCase());
382
+ current = actual === undefined ? undefined : current[actual];
383
+ }
384
+ }
385
+ return current;
386
+ }
387
+
388
+ function substitute(value, refs) {
389
+ if (typeof value === "string") {
390
+ return value.replace(REFERENCE, (match, name, path) => {
391
+ const resolved = lookupReference(refs, name, path);
392
+ if (resolved === undefined || resolved === null || typeof resolved === "object") throw { reference: match };
393
+ return String(resolved);
394
+ });
395
+ }
396
+ if (Array.isArray(value)) return value.map((entry) => substitute(entry, refs));
397
+ if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, substitute(entry, refs)]));
398
+ return value;
399
+ }
400
+
401
+ /** decodeURIComponent that answers null for malformed percent-encoding instead of throwing. */
402
+ function safeDecode(text) {
403
+ try {
404
+ return decodeURIComponent(text);
405
+ } catch {
406
+ return null;
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Split a subrequest url into version, decoded path segments and query parameters. Malformed
412
+ * percent-encoding never throws: an undecodable path segment makes the url unresolvable (the caller
413
+ * answers NOT_FOUND like any other unknown resource) and an undecodable parameter is recorded in
414
+ * `invalid` so the route that reads it raises its own Salesforce error.
415
+ */
416
+ function parseSubrequestUrl(url) {
417
+ const match = /^\/services\/data\/(v\d{2}\.\d)\/([^?]*)(?:\?(.*))?$/.exec(url);
418
+ if (match === null) return null;
419
+ const query = {};
420
+ const invalid = new Set();
421
+ for (const pair of (match[3] ?? "").split("&")) {
422
+ if (pair.length === 0) continue;
423
+ const [rawKey, ...rest] = pair.split("=");
424
+ const key = safeDecode(rawKey.replace(/\+/g, " "));
425
+ const value = safeDecode(rest.join("=").replace(/\+/g, " "));
426
+ if (key === null) continue;
427
+ if (value === null) invalid.add(key);
428
+ else query[key] = value;
429
+ }
430
+ const segments = [];
431
+ for (const raw of match[2].split("/")) {
432
+ if (raw.length === 0) continue;
433
+ const segment = safeDecode(raw);
434
+ if (segment === null) return null;
435
+ segments.push(segment);
436
+ }
437
+ return { version: match[1], segments, query, invalid };
438
+ }
439
+
440
+ const SOBJECT_STATUSES = { INVALID_SESSION_ID: 401, INSUFFICIENT_ACCESS_OR_READONLY: 403, NOT_FOUND: 404, ENTITY_IS_DELETED: 404, INVALID_TYPE: 404 };
441
+ const QUERY_STATUSES = { INVALID_SESSION_ID: 401, INSUFFICIENT_ACCESS_OR_READONLY: 403, NOT_FOUND: 404 };
442
+
443
+ /** Dispatch one composite subrequest; returns `{ status, body, headers }` or throws a RecordError. */
444
+ function dispatch(session, method, parsed, byteLimit) {
445
+ const { version, segments, query: params, invalid } = parsed;
446
+ const input = { version };
447
+ if (invalid.has("fields")) throw recordError("INVALID_FIELD", "the fields parameter is not valid percent-encoded UTF-8", ["fields"]);
448
+ const [head, type, third, fourth] = segments;
449
+ if (head === "limits" && segments.length === 1 && method === "GET") return { status: 200, body: limits(session, input), headers: {} };
450
+ if ((head === "query" || head === "queryAll") && segments.length === 1 && method === "GET") {
451
+ if (invalid.has("q")) throw recordError("MALFORMED_QUERY", "the q parameter is not valid percent-encoded UTF-8");
452
+ if (typeof params.q !== "string") throw recordError("MALFORMED_QUERY", "the q parameter is required");
453
+ return { status: 200, body: query(session, { ...input, q: params.q, includeDeleted: head === "queryAll" }, byteLimit), headers: {}, kind: "query" };
454
+ }
455
+ if (head === "sobjects" && segments.length === 2) {
456
+ if (method === "POST") return { status: 201, kind: "create", body: create(session, { ...input, sobjectType: type, record: parsed.body ?? {} }) };
457
+ if (method === "GET") return { status: 200, body: basicInfo(session, { ...input, sobjectType: type }), headers: {} };
458
+ }
459
+ if (head === "sobjects" && segments.length === 3) {
460
+ if (third === "describe" && method === "GET") return { status: 200, body: describe(session, { ...input, sobjectType: type }), headers: {} };
461
+ if (method === "GET") return { status: 200, body: retrieve(session, { ...input, sobjectType: type, id: third, ...(typeof params.fields === "string" ? { fields: params.fields.split(",").map((part) => part.trim()).filter((part) => part.length > 0) } : {}) }), headers: {} };
462
+ if (method === "PATCH") {
463
+ update(session, { ...input, sobjectType: type, id: third, record: parsed.body ?? {} });
464
+ return { status: 204, body: null, headers: {} };
465
+ }
466
+ if (method === "DELETE") {
467
+ remove(session, { ...input, sobjectType: type, id: third });
468
+ return { status: 204, body: null, headers: {} };
469
+ }
470
+ }
471
+ if (head === "sobjects" && segments.length === 4) {
472
+ if (method === "GET") return { status: 200, body: retrieve(session, { ...input, sobjectType: type, externalIdField: third, value: fourth, ...(typeof params.fields === "string" ? { fields: params.fields.split(",").map((part) => part.trim()).filter((part) => part.length > 0) } : {}) }), headers: {} };
473
+ if (method === "PATCH") return { status: 200, body: upsert(session, { ...input, sobjectType: type, externalIdField: third, value: fourth, record: parsed.body ?? {} }), headers: {} };
474
+ }
475
+ throw recordError("NOT_FOUND", NOT_FOUND_MESSAGE);
476
+ }
477
+
478
+ function composite(session, input) {
479
+ const outerVersion = requireVersion(session, input);
480
+ const subrequests = input.compositeRequest;
481
+ if (!Array.isArray(subrequests) || subrequests.length === 0) throw parserError("compositeRequest must be a non-empty array of subrequests");
482
+ if (subrequests.length > MAX_SUBREQUESTS) throw recordError("LIMIT_EXCEEDED", `Composite requests may contain at most ${MAX_SUBREQUESTS} subrequests`);
483
+ const seen = new Set();
484
+ let queries = 0;
485
+ for (const sub of subrequests) {
486
+ if (!isPlainObject(sub) || !["GET", "POST", "PATCH", "DELETE"].includes(sub.method) || typeof sub.url !== "string" || typeof sub.referenceId !== "string" || !/^[A-Za-z0-9_]+$/.test(sub.referenceId)) {
487
+ throw parserError("Each subrequest needs a method (GET, POST, PATCH or DELETE), a url and an alphanumeric referenceId");
488
+ }
489
+ if (seen.has(sub.referenceId)) throw parserError(`Duplicate referenceId: ${sub.referenceId}`);
490
+ seen.add(sub.referenceId);
491
+ if (/^\/services\/data\/v\d{2}\.\d\/query(All)?(\?|$)/.test(sub.url)) queries += 1;
492
+ }
493
+ if (queries > MAX_QUERY_SUBREQUESTS) throw recordError("LIMIT_EXCEEDED", `Composite requests may contain at most ${MAX_QUERY_SUBREQUESTS} query subrequests`);
494
+
495
+ const start = mark(session);
496
+ const refs = new Map();
497
+ const responses = [];
498
+ // Every entry is measured before it is admitted. Reads that would pass the response budget, and error
499
+ // entries echoing oversized caller text, become a fixed-size LIMIT_EXCEEDED entry (reserved below the
500
+ // 1 MiB cap: at most 25 × a few hundred bytes); write results are small and always admitted.
501
+ const budget = byteBudget(RESPONSE_BYTE_BUDGET, jsonBytes({ compositeResponse: [] }));
502
+ const admit = (entry, sub, alwaysFits) => {
503
+ const size = budget.measure({ ...entry, body: entry.body === null ? null : stripLimitInfo(entry.body) });
504
+ if (alwaysFits || budget.fits(size)) {
505
+ budget.add(size);
506
+ return entry;
507
+ }
508
+ const tooLarge = { body: [{ message: tooLargeMessage(RESPONSE_BYTE_BUDGET), errorCode: "LIMIT_EXCEEDED" }], httpHeaders: {}, httpStatusCode: 400, referenceId: sub.referenceId };
509
+ budget.add(budget.measure(tooLarge));
510
+ return tooLarge;
511
+ };
512
+ let failed = false;
513
+ for (const sub of subrequests) {
514
+ if (failed && input.allOrNone === true) {
515
+ responses.push(admit({ body: [{ errorCode: "PROCESSING_HALTED", message: HALTED }], httpHeaders: {}, httpStatusCode: 400, referenceId: sub.referenceId }, sub, true));
516
+ continue;
517
+ }
518
+ let outcome;
519
+ try {
520
+ const url = substitute(sub.url, refs);
521
+ const body = substitute(sub.body ?? null, refs);
522
+ const parsed = parseSubrequestUrl(url);
523
+ if (parsed === null) throw recordError("NOT_FOUND", NOT_FOUND_MESSAGE);
524
+ if (!isSupportedVersion(parsed.version)) throw recordError("NOT_FOUND", NOT_FOUND_MESSAGE);
525
+ // A query page shrinks to the bytes left (done:false with a working nextRecordsUrl).
526
+ const room = RESPONSE_BYTE_BUDGET - budget.used - jsonBytes({ body: null, httpHeaders: {}, httpStatusCode: 200, referenceId: sub.referenceId }) + 4 - 1;
527
+ const result = dispatch(session, sub.method, { ...parsed, body }, room);
528
+ const headers = result.kind === "create" ? { Location: recordUrl(parsed.version, canonicalType(parsed.segments[1]), result.body.id) } : {};
529
+ const admitted = admit({ body: result.body, httpHeaders: headers, httpStatusCode: result.status, referenceId: sub.referenceId }, sub, sub.method !== "GET");
530
+ outcome = admitted;
531
+ if (admitted.httpStatusCode >= 400) failed = true;
532
+ else if (result.body !== null && typeof result.body === "object") refs.set(sub.referenceId, result.body);
533
+ } catch (error) {
534
+ let entry;
535
+ if (isRecordError(error)) {
536
+ const table = /^\/services\/data\/v\d{2}\.\d\/(query|queryAll|parameterizedSearch)/.test(sub.url) ? QUERY_STATUSES : SOBJECT_STATUSES;
537
+ const entryBody = { message: error.message, errorCode: error.statusCode };
538
+ if (error.fields.length > 0) entryBody.fields = error.fields;
539
+ entry = { body: [entryBody], httpHeaders: {}, httpStatusCode: table[error.statusCode] ?? 400, referenceId: sub.referenceId };
540
+ } else if (isPlainObject(error) && typeof error.reference === "string") {
541
+ const problem = referenceError(error.reference);
542
+ entry = { body: [{ message: problem.message, errorCode: problem.statusCode }], httpHeaders: {}, httpStatusCode: 400, referenceId: sub.referenceId };
543
+ } else throw error;
544
+ outcome = admit(entry, sub, false);
545
+ failed = true;
546
+ }
547
+ responses.push(outcome);
548
+ }
549
+ if (failed && input.allOrNone === true) {
550
+ rollback(session, start);
551
+ return {
552
+ compositeResponse: responses.map((entry) => (entry.httpStatusCode >= 400 && entry.body?.[0]?.errorCode !== "PROCESSING_HALTED" ? entry : { body: [{ errorCode: "PROCESSING_HALTED", message: HALTED }], httpHeaders: {}, httpStatusCode: 400, referenceId: entry.referenceId })),
553
+ };
554
+ }
555
+ return { compositeResponse: responses.map((entry) => ({ ...entry, body: entry.body === null ? null : stripLimitInfo(entry.body) })) };
556
+ }
557
+
558
+ // ---------------------------------------------------------------------------------------------
559
+ // HTTP codecs (pure): Salesforce paths in, Salesforce bodies/headers out
560
+ // ---------------------------------------------------------------------------------------------
561
+
562
+ function route(decode, options = {}) {
563
+ return {
564
+ decode,
565
+ encode({ invocation, outcome }) {
566
+ const extra = { "content-type": JSON_TYPE };
567
+ if (outcome.status !== "ok") return { headers: responseHeaders(outcome, extra), body: { kind: "json", value: salesforceError(outcome) } };
568
+ if (options.empty) return { headers: responseHeaders(outcome), body: { kind: "empty" } };
569
+ if (options.location) {
570
+ const id = String(outcome.value?.id ?? "");
571
+ const version = typeof invocation.arguments?.version === "string" ? invocation.arguments.version : DEFAULT_VERSION;
572
+ const type = typeOfId(normalizeId(id) ?? "") ?? String(invocation.arguments?.sobjectType ?? "");
573
+ extra.location = recordUrl(version, type, id);
574
+ }
575
+ return { headers: responseHeaders(outcome, extra), body: { kind: "json", value: stripLimitInfo(outcome.value) } };
576
+ },
577
+ };
578
+ }
579
+
580
+ const versioned = (request) => ({ version: request.path.version });
581
+ const typed = (request) => ({ ...versioned(request), sobjectType: request.path.sobjectType });
582
+ const withFields = (request, args) => defined({ ...args, fields: list(request.query, "fields") });
583
+
584
+ function queryArguments(request, includeDeleted) {
585
+ return defined({ ...versioned(request), q: str(request.query, "q") ?? "", includeDeleted, batchSize: batchSizeHeader(request) });
586
+ }
587
+
588
+ const http = {
589
+ "list-versions": route(() => ({ arguments: {} })),
590
+ "get-userinfo": route(() => ({ arguments: {} })),
591
+ "get-limits": route((request) => ({ arguments: versioned(request) })),
592
+ "list-sobjects": route((request) => ({ arguments: versioned(request) })),
593
+ "get-sobject-basic-info": route((request) => ({ arguments: typed(request) })),
594
+ "create-record": route((request) => operationInput(request, { ...typed(request), record: jsonBody(request) }), { location: true }),
595
+ "retrieve-record": route((request) => ({ arguments: withFields(request, { ...typed(request), id: request.path.id }) })),
596
+ "update-record": route((request) => operationInput(request, { ...typed(request), id: request.path.id, record: jsonBody(request) }), { empty: true }),
597
+ "delete-record": route((request) => operationInput(request, { ...typed(request), id: request.path.id }), { empty: true }),
598
+ "retrieve-record-by-external-id": route((request) => ({ arguments: withFields(request, { ...typed(request), externalIdField: request.path.externalIdField, value: request.path.value }) })),
599
+ "upsert-record": route((request) => operationInput(request, { ...typed(request), externalIdField: request.path.externalIdField, value: request.path.value, record: jsonBody(request) })),
600
+ query: route((request) => ({ arguments: queryArguments(request, undefined) })),
601
+ "query-all": route((request) => ({ arguments: queryArguments(request, true) })),
602
+ "query-more": route((request) => ({ arguments: { ...versioned(request), locator: request.path.locator } })),
603
+ "query-all-more": route((request) => ({ arguments: { ...versioned(request), locator: request.path.locator, includeDeleted: true } })),
604
+ "parameterized-search": route((request) => ({ arguments: { ...versioned(request), ...jsonBody(request) } })),
605
+ "collections-create": route((request) => operationInput(request, { ...versioned(request), ...jsonBody(request) })),
606
+ "collections-update": route((request) => operationInput(request, { ...versioned(request), ...jsonBody(request) })),
607
+ "collections-delete": route((request) => operationInput(request, defined({ ...versioned(request), ids: list(request.query, "ids") ?? [], allOrNone: boolOrRaw(request.query, "allOrNone") }))),
608
+ "collections-retrieve": route((request) => ({ arguments: { ...typed(request), ...jsonBody(request) } })),
609
+ "collections-retrieve-get": route((request) => ({ arguments: defined({ ...typed(request), ids: list(request.query, "ids") ?? [], fields: list(request.query, "fields") ?? [] }) })),
610
+ "collections-upsert": route((request) => operationInput(request, { ...typed(request), externalIdField: request.path.externalIdField, ...jsonBody(request) })),
611
+ composite: route((request) => operationInput(request, { ...versioned(request), ...jsonBody(request) })),
612
+ };
613
+
614
+ const operations = {
615
+ "versions.list": operation(() => versionsTable()),
616
+ "userinfo.get": operation(userinfo),
617
+ "limits.get": operation(limits),
618
+ "sobjects.list": operation(sobjectsList),
619
+ "sobjects.basic-info": operation(basicInfo),
620
+ "sobjects.describe": operation(describe),
621
+ "records.create": operation(create),
622
+ "records.retrieve": operation(retrieve),
623
+ "records.update": operation(update),
624
+ "records.delete": operation(remove),
625
+ "records.upsert": operation(upsert),
626
+ "query.execute": operation(query),
627
+ "query.more": operation(queryMore),
628
+ "search.parameterized": operation(search),
629
+ "collections.create": operation(collectionsCreate),
630
+ "collections.retrieve": operation(collectionsRetrieve),
631
+ "collections.update": operation(collectionsUpdate),
632
+ "collections.delete": operation(collectionsDelete),
633
+ "collections.upsert": operation(collectionsUpsert),
634
+ "composite.execute": operation(composite),
635
+ };
636
+
637
+ export default { operations, http };