@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.
- package/LICENSE +201 -0
- package/README.md +156 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/api-limit-exceeded.scenario.json +11 -0
- package/firedrill/baseline.scenario.json +2213 -0
- package/firedrill/conformance.suite.json +23 -0
- package/firedrill/row-locked.scenario.json +11 -0
- package/firedrill/salesforce-api-limit-exceeded.drill.json +336 -0
- package/firedrill/salesforce-collections-composite.drill.json +277 -0
- package/firedrill/salesforce-denied.drill.json +74 -0
- package/firedrill/salesforce-fresh-install.drill.json +86 -0
- package/firedrill/salesforce-inactive-user.drill.json +43 -0
- package/firedrill/salesforce-invalid-session.drill.json +336 -0
- package/firedrill/salesforce-large-responses.drill.json +138 -0
- package/firedrill/salesforce-mcp-aliases.drill.json +156 -0
- package/firedrill/salesforce-profile-permissions.drill.json +241 -0
- package/firedrill/salesforce-read-only.drill.json +154 -0
- package/firedrill/salesforce-rest-flow.drill.json +714 -0
- package/firedrill/salesforce-row-locked.drill.json +227 -0
- package/firedrill/salesforce-sharing.drill.json +201 -0
- package/firedrill/salesforce-soql.drill.json +139 -0
- package/firedrill/salesforce-tight-limits.drill.json +336 -0
- package/firedrill/salesforce-write-committed-lost.drill.json +176 -0
- package/firedrill/tight-limits.scenario.json +41 -0
- package/firedrill/tools/salesforce/behavior.mjs +637 -0
- package/firedrill/tools/salesforce/lib/bytes.mjs +56 -0
- package/firedrill/tools/salesforce/lib/ids.mjs +68 -0
- package/firedrill/tools/salesforce/lib/match.mjs +352 -0
- package/firedrill/tools/salesforce/lib/records.mjs +506 -0
- package/firedrill/tools/salesforce/lib/schema.mjs +429 -0
- package/firedrill/tools/salesforce/lib/search.mjs +92 -0
- package/firedrill/tools/salesforce/lib/soql.mjs +1119 -0
- package/firedrill/tools/salesforce/lib/state.mjs +378 -0
- package/firedrill/tools/salesforce/lib/wire.mjs +109 -0
- package/firedrill/tools/salesforce/salesforce.tool.json +3939 -0
- package/firedrill/world.json +2705 -0
- package/firedrill/write-committed-lost.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +64 -0
- package/starter.json +2212 -0
- package/test/conformance.mjs +1122 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// Record semantics: sObject type resolution against the profile, body validation and coercion,
|
|
2
|
+
// derived fields, the seeded validation rule, uniqueness, sharing-aware create/retrieve/update/
|
|
3
|
+
// delete/upsert with Salesforce's cascade, and the record renderers (with one-level relationship
|
|
4
|
+
// paths such as `Account.Name`, `Owner.Username`, `Who.Type`). Domain failures are RecordErrors;
|
|
5
|
+
// the operation layer turns them into declared Tool errors or per-item results.
|
|
6
|
+
|
|
7
|
+
import { normalizeId, typeOfId } from "./ids.mjs";
|
|
8
|
+
import {
|
|
9
|
+
CHILD_RELATIONSHIPS,
|
|
10
|
+
FORECAST_LABELS,
|
|
11
|
+
NAMESPACES,
|
|
12
|
+
SYSTEM_FIELD_NAMES,
|
|
13
|
+
canonicalType,
|
|
14
|
+
fieldByName,
|
|
15
|
+
isRecordType,
|
|
16
|
+
nameFieldOf,
|
|
17
|
+
relationshipByName,
|
|
18
|
+
stageByName,
|
|
19
|
+
} from "./schema.mjs";
|
|
20
|
+
import {
|
|
21
|
+
canModify,
|
|
22
|
+
canRead,
|
|
23
|
+
clip,
|
|
24
|
+
childRowsOf,
|
|
25
|
+
fieldsOf,
|
|
26
|
+
getRow,
|
|
27
|
+
isDeleted,
|
|
28
|
+
nextId,
|
|
29
|
+
parseInstant,
|
|
30
|
+
permissions,
|
|
31
|
+
putRow,
|
|
32
|
+
queueEvent,
|
|
33
|
+
recordError,
|
|
34
|
+
rowsOf,
|
|
35
|
+
salesforceTimestamp,
|
|
36
|
+
timestampNow,
|
|
37
|
+
valueOf,
|
|
38
|
+
} from "./state.mjs";
|
|
39
|
+
|
|
40
|
+
export const NOT_FOUND_MESSAGE = "The requested resource does not exist";
|
|
41
|
+
export const INSUFFICIENT_ACCESS = "insufficient access rights on object id";
|
|
42
|
+
|
|
43
|
+
export function unsupportedTypeMessage(name) {
|
|
44
|
+
return `sObject type '${clip(name)}' is not supported. If you are attempting to use a custom object, be sure to append the '__c' after the entity name. Please reference your WSDL or the describe call for the appropriate names.`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------------------------
|
|
48
|
+
// Type resolution
|
|
49
|
+
// ---------------------------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** An sObject the caller's profile can read (`INVALID_TYPE` otherwise — unknown and unreadable look alike). */
|
|
52
|
+
export function requireReadableType(session, name) {
|
|
53
|
+
const type = canonicalType(name);
|
|
54
|
+
if (type === null || !permissions(session, type).read) throw recordError("INVALID_TYPE", unsupportedTypeMessage(String(name ?? "")));
|
|
55
|
+
return type;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A writable record sObject: unknown/unreadable → INVALID_TYPE, User/Profile → INVALID_TYPE_FOR_OPERATION. */
|
|
59
|
+
export function requireWritableType(session, name, action) {
|
|
60
|
+
const type = requireReadableType(session, name);
|
|
61
|
+
if (!isRecordType(type)) {
|
|
62
|
+
throw recordError("INVALID_TYPE_FOR_OPERATION", `Cannot ${action} ${type} through this Tool; User and Profile are read only`);
|
|
63
|
+
}
|
|
64
|
+
return type;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function requireObjectPermission(session, type, action) {
|
|
68
|
+
if (!permissions(session, type)[action]) throw recordError("INSUFFICIENT_ACCESS_OR_READONLY", INSUFFICIENT_ACCESS);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------------------------
|
|
72
|
+
// Lookups
|
|
73
|
+
// ---------------------------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export function requireId(value) {
|
|
76
|
+
const id = normalizeId(value);
|
|
77
|
+
if (id === null) throw recordError("MALFORMED_ID", `malformed id ${clip(value)}`);
|
|
78
|
+
return id;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A visible, non-deleted record of `type` by id (NOT_FOUND / ENTITY_IS_DELETED / MALFORMED_ID). */
|
|
82
|
+
export function requireRecord(session, type, rawId) {
|
|
83
|
+
const id = requireId(rawId);
|
|
84
|
+
const row = typeOfId(id) === type ? getRow(session, type, id) : null;
|
|
85
|
+
if (row === null) throw recordError("NOT_FOUND", NOT_FOUND_MESSAGE);
|
|
86
|
+
if (isDeleted(row)) throw recordError("ENTITY_IS_DELETED", "entity is deleted");
|
|
87
|
+
if (!canRead(session, type, row)) throw recordError("NOT_FOUND", NOT_FOUND_MESSAGE);
|
|
88
|
+
return row;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The external-id field of a type (`Id` is allowed as Salesforce does), else NOT_FOUND. */
|
|
92
|
+
export function requireExternalIdField(session, type, name) {
|
|
93
|
+
const field = fieldByName(fieldsOf(session, type), name);
|
|
94
|
+
if (field === null || (field.name !== "Id" && !field.externalId)) {
|
|
95
|
+
throw recordError("NOT_FOUND", `Provided external ID field does not exist or is not accessible: ${clip(name)}`);
|
|
96
|
+
}
|
|
97
|
+
return field;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Non-deleted records whose external-id field equals `value` (case-insensitive), sharing ignored. */
|
|
101
|
+
export function findByExternalId(session, type, field, value) {
|
|
102
|
+
if (field.name === "Id") {
|
|
103
|
+
const id = normalizeId(value);
|
|
104
|
+
if (id === null) return [];
|
|
105
|
+
const row = typeOfId(id) === type ? getRow(session, type, id) : null;
|
|
106
|
+
return row === null || isDeleted(row) ? [] : [row];
|
|
107
|
+
}
|
|
108
|
+
const wanted = String(value).toLowerCase();
|
|
109
|
+
return rowsOf(session, type).filter((row) => !isDeleted(row) && String(valueOf(type, row, field.name) ?? "").toLowerCase() === wanted && valueOf(type, row, field.name) !== null);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Every non-deleted (or all, with `includeDeleted`) record of a readable type the caller may see. */
|
|
113
|
+
export function visibleRows(session, type, includeDeleted = false) {
|
|
114
|
+
return rowsOf(session, type).filter((row) => (includeDeleted || !isDeleted(row)) && canRead(session, type, row));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------------------------
|
|
118
|
+
// Validation and coercion
|
|
119
|
+
// ---------------------------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
function jsonType(value) {
|
|
122
|
+
if (value === null) return "VALUE_NULL";
|
|
123
|
+
if (typeof value === "string") return "VALUE_STRING";
|
|
124
|
+
if (typeof value === "boolean") return value ? "VALUE_TRUE" : "VALUE_FALSE";
|
|
125
|
+
if (typeof value === "number") return Number.isInteger(value) ? "VALUE_NUMBER_INT" : "VALUE_NUMBER_FLOAT";
|
|
126
|
+
return Array.isArray(value) ? "START_ARRAY" : "START_OBJECT";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function deserializeError(kind, value) {
|
|
130
|
+
const rendered = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
131
|
+
return recordError("JSON_PARSER_ERROR", `Cannot deserialize instance of ${kind} from ${jsonType(value)} value ${clip(rendered)} or request may be missing a required field`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function coerceText(field, value) {
|
|
135
|
+
let text;
|
|
136
|
+
if (typeof value === "string") text = value;
|
|
137
|
+
else if (typeof value === "number" || typeof value === "boolean") text = String(value);
|
|
138
|
+
else throw deserializeError("string", value);
|
|
139
|
+
if (field.length !== null && text.length > field.length) {
|
|
140
|
+
throw recordError("INVALID_FIELD", `${field.label}: data value too large: ${text.slice(0, 40)}${text.length > 40 ? "…" : ""} (max length=${field.length})`, [field.name]);
|
|
141
|
+
}
|
|
142
|
+
if (field.type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)) {
|
|
143
|
+
throw recordError("INVALID_FIELD", `${field.label}: invalid email address: ${clip(text)}`, [field.name]);
|
|
144
|
+
}
|
|
145
|
+
return text;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function coerceNumber(field, value) {
|
|
149
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw deserializeError(field.type === "int" ? "int" : "double", value);
|
|
150
|
+
if (field.type === "int" && !Number.isInteger(value)) throw deserializeError("int", value);
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function coerceDate(field, value) {
|
|
155
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value) || parseInstant(value) === null) throw deserializeError("date", value);
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function coerceDatetime(field, value) {
|
|
160
|
+
const ms = typeof value === "string" ? parseInstant(value) : null;
|
|
161
|
+
if (ms === null) throw deserializeError("dateTime", value);
|
|
162
|
+
return salesforceTimestamp(ms);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function coercePicklist(field, value, type) {
|
|
166
|
+
if (typeof value !== "string") throw deserializeError("string", value);
|
|
167
|
+
const settable = field.picklist.filter((option) => !(type === "Lead" && field.name === "Status" && option === "Closed - Converted"));
|
|
168
|
+
const match = settable.find((option) => option.toLowerCase() === value.toLowerCase());
|
|
169
|
+
if (match === undefined) throw recordError("INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST", `${field.label}: bad value for restricted picklist field: ${clip(value)}`, [field.name]);
|
|
170
|
+
return match;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function coerceReference(session, field, value) {
|
|
174
|
+
const id = typeof value === "string" ? normalizeId(value) : null;
|
|
175
|
+
if (id === null) throw recordError("MALFORMED_ID", `malformed id ${clip(typeof value === "object" ? JSON.stringify(value) : String(value))}`, [field.name]);
|
|
176
|
+
const targetType = typeOfId(id);
|
|
177
|
+
if (targetType === null || !field.referenceTo.includes(targetType)) {
|
|
178
|
+
throw recordError("FIELD_INTEGRITY_EXCEPTION", `${field.label}: id value of incorrect type: ${clip(id)}`, [field.name]);
|
|
179
|
+
}
|
|
180
|
+
const target = getRow(session, targetType, id);
|
|
181
|
+
const alive = target !== null && !isDeleted(target) && (targetType !== "User" || target.IsActive === true);
|
|
182
|
+
if (!alive) throw recordError("INVALID_CROSS_REFERENCE_KEY", "invalid cross reference id", [field.name]);
|
|
183
|
+
return id;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function coerceValue(session, type, field, value) {
|
|
187
|
+
if (value === null) return null;
|
|
188
|
+
switch (field.type) {
|
|
189
|
+
case "boolean":
|
|
190
|
+
if (typeof value !== "boolean") throw deserializeError("boolean", value);
|
|
191
|
+
return value;
|
|
192
|
+
case "int":
|
|
193
|
+
case "double":
|
|
194
|
+
case "currency":
|
|
195
|
+
case "percent":
|
|
196
|
+
return coerceNumber(field, value);
|
|
197
|
+
case "date":
|
|
198
|
+
return coerceDate(field, value);
|
|
199
|
+
case "datetime":
|
|
200
|
+
return coerceDatetime(field, value);
|
|
201
|
+
case "picklist":
|
|
202
|
+
return coercePicklist(field, value, type);
|
|
203
|
+
case "reference":
|
|
204
|
+
return coerceReference(session, field, value);
|
|
205
|
+
default:
|
|
206
|
+
return coerceText(field, value);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function readOnlyError(name) {
|
|
211
|
+
return recordError("INVALID_FIELD_FOR_INSERT_UPDATE", `Unable to create/update fields: ${clip(name)}. Please check the security settings of this field and verify that it is read/write for your profile or permission set.`, [name]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Validate a request body against the catalogue: returns the coerced `{ fieldName: value }` map of
|
|
216
|
+
* the fields actually sent (catalogue spelling). `Id` must match `targetId` (update) and is never
|
|
217
|
+
* accepted on create; `attributes` is ignored.
|
|
218
|
+
*/
|
|
219
|
+
function coerceBody(session, type, body, targetId) {
|
|
220
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw deserializeError("object", body);
|
|
221
|
+
const fields = fieldsOf(session, type);
|
|
222
|
+
const sent = {};
|
|
223
|
+
for (const [key, raw] of Object.entries(body)) {
|
|
224
|
+
if (key === "attributes") continue;
|
|
225
|
+
const field = fieldByName(fields, key);
|
|
226
|
+
if (field === null) throw recordError("INVALID_FIELD", `No such column '${clip(key)}' on sobject of type ${type}`, [key]);
|
|
227
|
+
if (field.name === "Id") {
|
|
228
|
+
if (targetId !== null && typeof raw === "string" && normalizeId(raw) === targetId) continue;
|
|
229
|
+
throw readOnlyError("Id");
|
|
230
|
+
}
|
|
231
|
+
if (field.readOnly || SYSTEM_FIELD_NAMES.includes(field.name)) throw readOnlyError(field.name);
|
|
232
|
+
sent[field.name] = coerceValue(session, type, field, raw);
|
|
233
|
+
}
|
|
234
|
+
return sent;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function requireRequired(session, type, values) {
|
|
238
|
+
const missing = fieldsOf(session, type).filter((field) => field.required && !field.readOnly && (values[field.name] === null || values[field.name] === undefined || values[field.name] === ""));
|
|
239
|
+
if (missing.length > 0) {
|
|
240
|
+
throw recordError("REQUIRED_FIELD_MISSING", `Required fields are missing: [${missing.map((field) => field.name).join(", ")}]`, missing.map((field) => field.name));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Derived fields recomputed on every write (Name, stage flags, IsClosed) plus the validation rule. */
|
|
245
|
+
function derive(type, values, previous, sent) {
|
|
246
|
+
const next = { ...values };
|
|
247
|
+
if (type === "Contact" || type === "Lead") {
|
|
248
|
+
next.Name = [next.FirstName, next.LastName].filter((part) => typeof part === "string" && part.length > 0).join(" ");
|
|
249
|
+
}
|
|
250
|
+
if (type === "Lead" && previous === null) {
|
|
251
|
+
next.IsConverted = false;
|
|
252
|
+
next.ConvertedDate = null;
|
|
253
|
+
next.ConvertedAccountId = null;
|
|
254
|
+
next.ConvertedContactId = null;
|
|
255
|
+
next.ConvertedOpportunityId = null;
|
|
256
|
+
}
|
|
257
|
+
if (type === "Opportunity") {
|
|
258
|
+
const stage = stageByName(next.StageName);
|
|
259
|
+
if (stage !== null) {
|
|
260
|
+
const stageChanged = previous === null || previous.StageName !== next.StageName;
|
|
261
|
+
if (stageChanged && !("Probability" in sent)) next.Probability = stage.probability;
|
|
262
|
+
next.IsClosed = stage.closed;
|
|
263
|
+
next.IsWon = stage.won;
|
|
264
|
+
next.ForecastCategory = stage.forecastCategory;
|
|
265
|
+
next.ForecastCategoryName = FORECAST_LABELS[stage.forecastCategory];
|
|
266
|
+
if (stage.won && (typeof next.Amount !== "number" || next.Amount <= 0)) {
|
|
267
|
+
throw recordError("FIELD_CUSTOM_VALIDATION_EXCEPTION", "Closed Won opportunities must have an Amount.", ["Amount"]);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (type === "Task") next.IsClosed = next.Status === "Completed";
|
|
272
|
+
return next;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function requireUnique(session, type, values, selfId) {
|
|
276
|
+
for (const field of fieldsOf(session, type)) {
|
|
277
|
+
if (!field.unique) continue;
|
|
278
|
+
const value = values[field.name];
|
|
279
|
+
if (value === null || value === undefined) continue;
|
|
280
|
+
const wanted = String(value).toLowerCase();
|
|
281
|
+
const clash = rowsOf(session, type).find((row) => row.Id !== selfId && !isDeleted(row) && valueOf(type, row, field.name) !== null && String(valueOf(type, row, field.name)).toLowerCase() === wanted);
|
|
282
|
+
if (clash !== undefined) {
|
|
283
|
+
throw recordError("DUPLICATE_VALUE", `duplicate value found: ${field.name} duplicates value on record with id: ${clash.Id}`, [field.name]);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function requireNoParentCycle(session, values, selfId) {
|
|
289
|
+
let parentId = values.ParentId;
|
|
290
|
+
let hops = 0;
|
|
291
|
+
while (typeof parentId === "string") {
|
|
292
|
+
if (parentId === selfId) throw recordError("FIELD_INTEGRITY_EXCEPTION", "Parent Account ID: an account cannot be its own parent or one of its own subsidiaries", ["ParentId"]);
|
|
293
|
+
const parent = getRow(session, "Account", parentId);
|
|
294
|
+
parentId = parent === null ? null : valueOf("Account", parent, "ParentId");
|
|
295
|
+
hops += 1;
|
|
296
|
+
if (hops > 50) throw recordError("FIELD_INTEGRITY_EXCEPTION", "Parent Account ID: account hierarchy is too deep", ["ParentId"]);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------------------------------------
|
|
301
|
+
// Writes
|
|
302
|
+
// ---------------------------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
function stamp(session, row, created) {
|
|
305
|
+
const now = timestampNow(session);
|
|
306
|
+
const me = session.user.Id;
|
|
307
|
+
return {
|
|
308
|
+
...row,
|
|
309
|
+
...(created ? { CreatedDate: now, CreatedById: me } : {}),
|
|
310
|
+
LastModifiedDate: now,
|
|
311
|
+
LastModifiedById: me,
|
|
312
|
+
SystemModstamp: now,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Insert one record: permission, body, defaults, derived fields, uniqueness; queues `record.created`. */
|
|
317
|
+
export function createRecord(session, type, body) {
|
|
318
|
+
requireObjectPermission(session, type, "create");
|
|
319
|
+
const sent = coerceBody(session, type, body, null);
|
|
320
|
+
const values = {};
|
|
321
|
+
for (const field of fieldsOf(session, type)) {
|
|
322
|
+
if (SYSTEM_FIELD_NAMES.includes(field.name)) continue;
|
|
323
|
+
if (field.name in sent) values[field.name] = sent[field.name];
|
|
324
|
+
else if (field.name === "OwnerId") values.OwnerId = session.user.Id;
|
|
325
|
+
else if (field.picklistDefault !== null) values[field.name] = field.picklistDefault;
|
|
326
|
+
else values[field.name] = field.defaultValue;
|
|
327
|
+
}
|
|
328
|
+
requireRequired(session, type, values);
|
|
329
|
+
const fields = derive(type, values, null, sent);
|
|
330
|
+
if (type === "Account") requireNoParentCycle(session, fields, null);
|
|
331
|
+
requireUnique(session, type, fields, null);
|
|
332
|
+
const id = nextId(session, type);
|
|
333
|
+
const row = stamp(session, { Id: id, fields, IsDeleted: false, DeletedDate: null }, true);
|
|
334
|
+
putRow(session, NAMESPACES[type], id, row);
|
|
335
|
+
queueEvent(session, "record.created", { sobjectType: type, id, ownerId: fields.OwnerId, createdById: session.user.Id });
|
|
336
|
+
return row;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Partial update of a visible, editable record; queues `record.updated` when something changed. */
|
|
340
|
+
export function updateRecord(session, type, rawId, body) {
|
|
341
|
+
requireObjectPermission(session, type, "edit");
|
|
342
|
+
const current = requireRecord(session, type, rawId);
|
|
343
|
+
if (!canModify(session, type, current, "edit")) throw recordError("INSUFFICIENT_ACCESS_OR_READONLY", INSUFFICIENT_ACCESS);
|
|
344
|
+
if (type === "Lead" && current.fields.IsConverted === true) throw recordError("CANNOT_UPDATE_CONVERTED_LEAD", "cannot reference converted lead");
|
|
345
|
+
const sent = coerceBody(session, type, body, current.Id);
|
|
346
|
+
const values = { ...current.fields, ...sent };
|
|
347
|
+
requireRequired(session, type, values);
|
|
348
|
+
const fields = derive(type, values, current.fields, sent);
|
|
349
|
+
if (type === "Account") requireNoParentCycle(session, fields, current.Id);
|
|
350
|
+
requireUnique(session, type, fields, current.Id);
|
|
351
|
+
const changed = Object.keys(fields).filter((name) => !sameValue(fields[name], current.fields[name])).sort();
|
|
352
|
+
if (changed.length === 0) return { row: current, changed };
|
|
353
|
+
const row = stamp(session, { ...current, fields }, false);
|
|
354
|
+
putRow(session, NAMESPACES[type], current.Id, row);
|
|
355
|
+
queueEvent(session, "record.updated", { sobjectType: type, id: current.Id, changedFields: changed });
|
|
356
|
+
return { row, changed };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function sameValue(left, right) {
|
|
360
|
+
return (left ?? null) === (right ?? null);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function softDelete(session, type, row, cascadedFrom) {
|
|
364
|
+
if (isDeleted(row)) return;
|
|
365
|
+
const now = timestampNow(session);
|
|
366
|
+
const next = stamp(session, { ...row, IsDeleted: true, DeletedDate: now }, false);
|
|
367
|
+
putRow(session, NAMESPACES[type], row.Id, next);
|
|
368
|
+
queueEvent(session, "record.deleted", { sobjectType: type, id: row.Id, cascadedFrom });
|
|
369
|
+
for (const relationship of CHILD_RELATIONSHIPS[type] ?? []) {
|
|
370
|
+
if (!relationship.cascadeDelete) continue;
|
|
371
|
+
for (const snapshot of childRowsOf(session, relationship.childSObject, relationship.field, row.Id)) {
|
|
372
|
+
// Re-read: an earlier cascade branch (e.g. Contact → Tasks before Account → Tasks) may have deleted it already.
|
|
373
|
+
const child = getRow(session, relationship.childSObject, snapshot.Id);
|
|
374
|
+
if (child !== null && !isDeleted(child)) softDelete(session, relationship.childSObject, child, row.Id);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Soft-delete a visible, deletable record and cascade to its children as Salesforce does. */
|
|
380
|
+
export function deleteRecord(session, type, rawId) {
|
|
381
|
+
requireObjectPermission(session, type, "delete");
|
|
382
|
+
const current = requireRecord(session, type, rawId);
|
|
383
|
+
if (!canModify(session, type, current, "delete")) throw recordError("INSUFFICIENT_ACCESS_OR_READONLY", INSUFFICIENT_ACCESS);
|
|
384
|
+
softDelete(session, type, current, null);
|
|
385
|
+
return current.Id;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Upsert by external id: exactly one non-deleted match → update, none → create with the field set. */
|
|
389
|
+
export function upsertRecord(session, type, externalIdFieldName, value, body) {
|
|
390
|
+
const field = requireExternalIdField(session, type, externalIdFieldName);
|
|
391
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw deserializeError("object", body);
|
|
392
|
+
const bodyKey = Object.keys(body).find((key) => key.toLowerCase() === field.name.toLowerCase());
|
|
393
|
+
if (bodyKey !== undefined && field.name !== "Id" && String(body[bodyKey]).toLowerCase() !== String(value).toLowerCase()) {
|
|
394
|
+
throw recordError("INVALID_FIELD", `External ID field value in body does not match the value in the URL: ${field.name}`, [field.name]);
|
|
395
|
+
}
|
|
396
|
+
const matches = findByExternalId(session, type, field, value);
|
|
397
|
+
if (field.name === "Id") {
|
|
398
|
+
if (normalizeId(value) === null) throw recordError("MALFORMED_ID", `malformed id ${clip(value)}`);
|
|
399
|
+
const { row } = updateRecord(session, type, value, body);
|
|
400
|
+
return { id: row.Id, created: false };
|
|
401
|
+
}
|
|
402
|
+
const cleaned = Object.fromEntries(Object.entries(body).filter(([key]) => key !== bodyKey));
|
|
403
|
+
if (matches.length === 1) {
|
|
404
|
+
const { row } = updateRecord(session, type, matches[0].Id, cleaned);
|
|
405
|
+
return { id: row.Id, created: false };
|
|
406
|
+
}
|
|
407
|
+
const row = createRecord(session, type, { ...cleaned, [field.name]: value });
|
|
408
|
+
return { id: row.Id, created: true };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------------------------
|
|
412
|
+
// Rendering
|
|
413
|
+
// ---------------------------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
export function recordUrl(version, type, id) {
|
|
416
|
+
return `/services/data/${version}/sobjects/${type}/${id}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function attributesOf(version, type, id) {
|
|
420
|
+
return { type, url: recordUrl(version, type, id) };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The full record JSON (every catalogue field in order) with Salesforce's `attributes`. */
|
|
424
|
+
export function renderRecord(session, type, row, version) {
|
|
425
|
+
const out = { attributes: attributesOf(version, type, row.Id) };
|
|
426
|
+
for (const field of fieldsOf(session, type)) out[field.name] = valueOf(type, row, field.name);
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** A parsed field path: `{ field }` or `{ via, field | typeField }` (one relationship level). */
|
|
431
|
+
export function resolvePath(session, type, path, wording) {
|
|
432
|
+
const parts = String(path).split(".");
|
|
433
|
+
const fields = fieldsOf(session, type);
|
|
434
|
+
const unknown = (name) =>
|
|
435
|
+
wording === "entity"
|
|
436
|
+
? recordError("INVALID_FIELD", `No such column '${clip(name)}' on entity '${type}'. If you are attempting to use a custom field, be sure to append the '__c' after the custom field name. Please reference your WSDL or the describe call for the appropriate names.`, [name])
|
|
437
|
+
: recordError("INVALID_FIELD", `No such column '${clip(name)}' on sobject of type ${type}`, [name]);
|
|
438
|
+
if (parts.length === 1) {
|
|
439
|
+
const field = fieldByName(fields, parts[0]);
|
|
440
|
+
if (field === null) throw unknown(parts[0]);
|
|
441
|
+
return { path: field.name, field, via: null };
|
|
442
|
+
}
|
|
443
|
+
if (parts.length > 2) {
|
|
444
|
+
const message = `relationship path '${clip(path)}': relationship fields deeper than one level are unsupported in this synthetic Salesforce Tool`;
|
|
445
|
+
throw wording === "entity" ? recordError("MALFORMED_QUERY", message) : recordError("INVALID_FIELD", message, [String(path)]);
|
|
446
|
+
}
|
|
447
|
+
const via = relationshipByName(fields, parts[0]);
|
|
448
|
+
if (via === null) throw recordError("INVALID_FIELD", `Didn't understand relationship '${clip(parts[0])}' in field path. If you are attempting to use a custom relationship, be sure to append the '__r' after the custom relationship name. Please reference your WSDL or the describe call for the appropriate names.`, [parts[0]]);
|
|
449
|
+
if (parts[1].toLowerCase() === "type") return { path: `${via.relationshipName}.Type`, field: null, via, typeField: true };
|
|
450
|
+
const targets = via.referenceTo.map((target) => ({ target, field: fieldByName(fieldsOf(session, target), parts[1]) })).filter((entry) => entry.field !== null);
|
|
451
|
+
if (targets.length === 0) throw recordError("INVALID_FIELD", `No such column '${clip(parts[1])}' on entity '${via.referenceTo[0]}'. If you are attempting to use a custom field, be sure to append the '__c' after the custom field name. Please reference your WSDL or the describe call for the appropriate names.`, [parts[1]]);
|
|
452
|
+
return { path: `${via.relationshipName}.${targets[0].field.name}`, field: targets[0].field, via, targetFields: Object.fromEntries(targets.map((entry) => [entry.target, entry.field.name])) };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function relatedRow(session, type, row, resolved) {
|
|
456
|
+
const id = valueOf(type, row, resolved.via.name);
|
|
457
|
+
if (typeof id !== "string") return null;
|
|
458
|
+
const targetType = typeOfId(id);
|
|
459
|
+
if (targetType === null || !resolved.via.referenceTo.includes(targetType)) return null;
|
|
460
|
+
const target = getRow(session, targetType, id);
|
|
461
|
+
return target === null ? null : { targetType, target };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The value a resolved path yields for one row (relationship fields follow the lookup). */
|
|
465
|
+
export function readPath(session, type, row, resolved) {
|
|
466
|
+
if (resolved.via === null) return valueOf(type, row, resolved.field.name);
|
|
467
|
+
const related = relatedRow(session, type, row, resolved);
|
|
468
|
+
if (related === null) return null;
|
|
469
|
+
if (resolved.typeField) return related.targetType;
|
|
470
|
+
const name = resolved.targetFields[related.targetType];
|
|
471
|
+
return name === undefined ? null : valueOf(related.targetType, related.target, name);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Render a row as `{ attributes, ...paths }` with relationship paths nested as Salesforce does. */
|
|
475
|
+
export function renderPaths(session, type, row, version, resolvedPaths) {
|
|
476
|
+
const out = { attributes: attributesOf(version, type, row.Id) };
|
|
477
|
+
for (const resolved of resolvedPaths) {
|
|
478
|
+
if (resolved.via === null) {
|
|
479
|
+
out[resolved.field.name] = valueOf(type, row, resolved.field.name);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const key = resolved.via.relationshipName;
|
|
483
|
+
const related = relatedRow(session, type, row, resolved);
|
|
484
|
+
if (related === null) {
|
|
485
|
+
if (!(key in out)) out[key] = null;
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (out[key] === null || out[key] === undefined) out[key] = { attributes: attributesOf(version, related.targetType, related.target.Id) };
|
|
489
|
+
if (resolved.typeField) out[key].Type = related.targetType;
|
|
490
|
+
else {
|
|
491
|
+
const name = resolved.targetFields[related.targetType];
|
|
492
|
+
if (name !== undefined) out[key][name] = valueOf(related.targetType, related.target, name);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** The five most recently modified visible records of a type, as `{ attributes, Id, Name }`. */
|
|
499
|
+
export function recentItems(session, type, version) {
|
|
500
|
+
const nameField = nameFieldOf(type);
|
|
501
|
+
return visibleRows(session, type)
|
|
502
|
+
.slice()
|
|
503
|
+
.sort((left, right) => (left.LastModifiedDate < right.LastModifiedDate ? 1 : left.LastModifiedDate > right.LastModifiedDate ? -1 : left.Id < right.Id ? -1 : 1))
|
|
504
|
+
.slice(0, 5)
|
|
505
|
+
.map((row) => ({ attributes: attributesOf(version, type, row.Id), Id: row.Id, [nameField]: valueOf(type, row, nameField) }));
|
|
506
|
+
}
|