@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,429 @@
1
+ // The synthetic org's object model: the standard field catalogue per sObject, picklists, the
2
+ // opportunity stage table, key prefixes, relationships and the describe renderers. Pure data and
3
+ // pure functions only; custom (`__c`) fields are merged in from the `custom-fields` state rows.
4
+
5
+ export const DEFAULT_VERSION = "v62.0";
6
+ export const MIN_VERSION = 46;
7
+ export const MAX_VERSION = 66;
8
+
9
+ /** Release labels for `GET /services/data` (the API versions this org answers). */
10
+ const RELEASES = [
11
+ "Summer '19", "Winter '20", "Spring '20", "Summer '20", "Winter '21", "Spring '21", "Summer '21",
12
+ "Winter '22", "Spring '22", "Summer '22", "Winter '23", "Spring '23", "Summer '23", "Winter '24",
13
+ "Spring '24", "Summer '24", "Winter '25", "Spring '25", "Summer '25", "Winter '26", "Spring '26",
14
+ ];
15
+
16
+ export function versionsTable() {
17
+ const rows = [];
18
+ for (let major = MIN_VERSION; major <= MAX_VERSION; major += 1) {
19
+ rows.push({ label: RELEASES[major - MIN_VERSION], url: `/services/data/v${major}.0`, version: `${major}.0` });
20
+ }
21
+ return rows;
22
+ }
23
+
24
+ /** `v62.0` → true when the org serves that API version. */
25
+ export function isSupportedVersion(version) {
26
+ if (typeof version !== "string") return false;
27
+ const match = /^v(\d{2})\.0$/.exec(version);
28
+ if (match === null) return false;
29
+ const major = Number(match[1]);
30
+ return major >= MIN_VERSION && major <= MAX_VERSION;
31
+ }
32
+
33
+ export const RECORD_TYPES = ["Account", "Contact", "Lead", "Opportunity", "Task"];
34
+ export const READ_ONLY_TYPES = ["User", "Profile"];
35
+ export const ALL_TYPES = [...RECORD_TYPES, ...READ_ONLY_TYPES];
36
+
37
+ export const KEY_PREFIXES = {
38
+ Account: "001",
39
+ Contact: "003",
40
+ Lead: "00Q",
41
+ Opportunity: "006",
42
+ Task: "00T",
43
+ User: "005",
44
+ Profile: "00e",
45
+ Organization: "00D",
46
+ };
47
+
48
+ export const NAMESPACES = {
49
+ Account: "accounts",
50
+ Contact: "contacts",
51
+ Lead: "leads",
52
+ Opportunity: "opportunities",
53
+ Task: "tasks",
54
+ User: "users",
55
+ Profile: "profiles",
56
+ };
57
+
58
+ const LABELS = {
59
+ Account: ["Account", "Accounts"],
60
+ Contact: ["Contact", "Contacts"],
61
+ Lead: ["Lead", "Leads"],
62
+ Opportunity: ["Opportunity", "Opportunities"],
63
+ Task: ["Task", "Tasks"],
64
+ User: ["User", "Users"],
65
+ Profile: ["Profile", "Profiles"],
66
+ };
67
+
68
+ export function typeByPrefix(prefix) {
69
+ for (const [type, value] of Object.entries(KEY_PREFIXES)) if (value === prefix && type !== "Organization") return type;
70
+ return null;
71
+ }
72
+
73
+ /** Case-insensitive sObject name → catalogue spelling, or null. */
74
+ export function canonicalType(name) {
75
+ if (typeof name !== "string") return null;
76
+ const lower = name.toLowerCase();
77
+ return ALL_TYPES.find((type) => type.toLowerCase() === lower) ?? null;
78
+ }
79
+
80
+ export const PICKLISTS = {
81
+ AccountType: ["Prospect", "Customer - Direct", "Customer - Channel", "Channel Partner / Reseller", "Installation Partner", "Technology Partner", "Other"],
82
+ Industry: ["Agriculture", "Banking", "Biotechnology", "Consulting", "Education", "Energy", "Finance", "Healthcare", "Manufacturing", "Media", "Retail", "Technology", "Telecommunications", "Other"],
83
+ Salutation: ["Mr.", "Ms.", "Mrs.", "Dr.", "Prof."],
84
+ LeadSource: ["Web", "Phone Inquiry", "Partner Referral", "Purchased List", "Other"],
85
+ LeadStatus: ["Open - Not Contacted", "Working - Contacted", "Closed - Not Converted", "Closed - Converted"],
86
+ Rating: ["Hot", "Warm", "Cold"],
87
+ OpportunityType: ["Existing Customer - Upgrade", "Existing Customer - Replacement", "Existing Customer - Downgrade", "New Customer"],
88
+ TaskStatus: ["Not Started", "In Progress", "Completed", "Waiting on someone else", "Deferred"],
89
+ TaskPriority: ["High", "Normal", "Low"],
90
+ };
91
+
92
+ /** StageName → default probability, forecast category, closed and won flags. */
93
+ export const STAGES = [
94
+ { name: "Prospecting", probability: 10, forecastCategory: "Pipeline", closed: false, won: false },
95
+ { name: "Qualification", probability: 10, forecastCategory: "Pipeline", closed: false, won: false },
96
+ { name: "Needs Analysis", probability: 20, forecastCategory: "Pipeline", closed: false, won: false },
97
+ { name: "Value Proposition", probability: 30, forecastCategory: "Pipeline", closed: false, won: false },
98
+ { name: "Id. Decision Makers", probability: 60, forecastCategory: "Pipeline", closed: false, won: false },
99
+ { name: "Perception Analysis", probability: 70, forecastCategory: "Pipeline", closed: false, won: false },
100
+ { name: "Proposal/Price Quote", probability: 75, forecastCategory: "BestCase", closed: false, won: false },
101
+ { name: "Negotiation/Review", probability: 90, forecastCategory: "Commit", closed: false, won: false },
102
+ { name: "Closed Won", probability: 100, forecastCategory: "Closed", closed: true, won: true },
103
+ { name: "Closed Lost", probability: 0, forecastCategory: "Omitted", closed: true, won: false },
104
+ ];
105
+
106
+ export const FORECAST_LABELS = { Pipeline: "Pipeline", BestCase: "Best Case", Commit: "Commit", Closed: "Closed", Omitted: "Omitted" };
107
+
108
+ export function stageByName(name) {
109
+ return STAGES.find((stage) => stage.name === name) ?? null;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------------------------
113
+ // Field catalogue
114
+ // ---------------------------------------------------------------------------------------------
115
+
116
+ function f(name, type, options = {}) {
117
+ return { name, label: options.label ?? labelOf(name), type, length: null, precision: null, scale: null, required: false, readOnly: false, picklist: null, picklistDefault: null, defaultValue: null, referenceTo: null, relationshipName: null, nameField: false, externalId: false, unique: false, custom: false, ...options };
118
+ }
119
+
120
+ function labelOf(name) {
121
+ return name
122
+ .replace(/__c$/, "")
123
+ .replace(/Id$/, (match, offset) => (offset > 0 ? " ID" : match))
124
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
125
+ .replace(/_/g, " ")
126
+ .trim();
127
+ }
128
+
129
+ const text = (name, length = 255, options = {}) => f(name, "string", { length, ...options });
130
+ const textarea = (name, length = 32000, options = {}) => f(name, "textarea", { length, ...options });
131
+ const picklist = (name, values, options = {}) => f(name, "picklist", { picklist: values, length: 255, ...options });
132
+ const reference = (name, referenceTo, relationshipName, options = {}) => f(name, "reference", { referenceTo, relationshipName, length: 18, ...options });
133
+ const currency = (name, options = {}) => f(name, "currency", { precision: 18, scale: 2, ...options });
134
+ const percent = (name, options = {}) => f(name, "percent", { precision: 3, scale: 0, ...options });
135
+ const int = (name, options = {}) => f(name, "int", { precision: 8, ...options });
136
+ const bool = (name, options = {}) => f(name, "boolean", { defaultValue: false, ...options });
137
+
138
+ export const SYSTEM_FIELDS = [
139
+ f("Id", "id", { label: "Record ID", length: 18, readOnly: true }),
140
+ bool("IsDeleted", { label: "Deleted", readOnly: true }),
141
+ f("CreatedDate", "datetime", { readOnly: true }),
142
+ reference("CreatedById", ["User"], "CreatedBy", { readOnly: true }),
143
+ f("LastModifiedDate", "datetime", { readOnly: true }),
144
+ reference("LastModifiedById", ["User"], "LastModifiedBy", { readOnly: true }),
145
+ f("SystemModstamp", "datetime", { label: "System Modstamp", readOnly: true }),
146
+ ];
147
+
148
+ const OWNER = reference("OwnerId", ["User"], "Owner", { label: "Owner ID" });
149
+
150
+ const STANDARD_FIELDS = {
151
+ Account: [
152
+ text("Name", 255, { label: "Account Name", required: true, nameField: true }),
153
+ picklist("Type", PICKLISTS.AccountType, { label: "Account Type" }),
154
+ picklist("Industry", PICKLISTS.Industry),
155
+ f("Website", "url", { length: 255 }),
156
+ f("Phone", "phone", { label: "Account Phone", length: 40 }),
157
+ text("BillingStreet", 255),
158
+ text("BillingCity", 40),
159
+ text("BillingState", 80, { label: "Billing State/Province" }),
160
+ text("BillingPostalCode", 20, { label: "Billing Zip/Postal Code" }),
161
+ text("BillingCountry", 80),
162
+ currency("AnnualRevenue"),
163
+ int("NumberOfEmployees", { label: "Employees" }),
164
+ textarea("Description", 32000, { label: "Account Description" }),
165
+ reference("ParentId", ["Account"], "Parent", { label: "Parent Account ID" }),
166
+ OWNER,
167
+ ],
168
+ Contact: [
169
+ text("FirstName", 40),
170
+ text("LastName", 80, { required: true }),
171
+ text("Name", 121, { label: "Full Name", readOnly: true, nameField: true }),
172
+ picklist("Salutation", PICKLISTS.Salutation),
173
+ f("Email", "email", { length: 80 }),
174
+ f("Phone", "phone", { label: "Business Phone", length: 40 }),
175
+ f("MobilePhone", "phone", { length: 40 }),
176
+ text("Title", 128),
177
+ text("Department", 80),
178
+ reference("AccountId", ["Account"], "Account", { label: "Account ID" }),
179
+ text("MailingCity", 40),
180
+ text("MailingCountry", 80),
181
+ picklist("LeadSource", PICKLISTS.LeadSource),
182
+ bool("HasOptedOutOfEmail", { label: "Email Opt Out" }),
183
+ textarea("Description", 32000, { label: "Contact Description" }),
184
+ OWNER,
185
+ ],
186
+ Lead: [
187
+ text("FirstName", 40),
188
+ text("LastName", 80, { required: true }),
189
+ text("Name", 121, { label: "Full Name", readOnly: true, nameField: true }),
190
+ text("Company", 255, { required: true }),
191
+ text("Title", 128),
192
+ f("Email", "email", { length: 80 }),
193
+ f("Phone", "phone", { length: 40 }),
194
+ f("Website", "url", { length: 255 }),
195
+ picklist("Status", PICKLISTS.LeadStatus, { picklistDefault: "Open - Not Contacted", required: true }),
196
+ picklist("LeadSource", PICKLISTS.LeadSource),
197
+ picklist("Industry", PICKLISTS.Industry),
198
+ picklist("Rating", PICKLISTS.Rating),
199
+ int("NumberOfEmployees", { label: "Employees" }),
200
+ text("City", 40),
201
+ text("Country", 80),
202
+ textarea("Description", 32000),
203
+ bool("IsConverted", { label: "Converted", readOnly: true }),
204
+ f("ConvertedDate", "date", { readOnly: true }),
205
+ reference("ConvertedAccountId", ["Account"], "ConvertedAccount", { readOnly: true }),
206
+ reference("ConvertedContactId", ["Contact"], "ConvertedContact", { readOnly: true }),
207
+ reference("ConvertedOpportunityId", ["Opportunity"], "ConvertedOpportunity", { readOnly: true }),
208
+ OWNER,
209
+ ],
210
+ Opportunity: [
211
+ text("Name", 120, { required: true, nameField: true }),
212
+ reference("AccountId", ["Account"], "Account", { label: "Account ID" }),
213
+ picklist("StageName", STAGES.map((stage) => stage.name), { label: "Stage", required: true }),
214
+ currency("Amount"),
215
+ f("CloseDate", "date", { required: true }),
216
+ percent("Probability", { label: "Probability (%)" }),
217
+ picklist("Type", PICKLISTS.OpportunityType, { label: "Opportunity Type" }),
218
+ picklist("LeadSource", PICKLISTS.LeadSource),
219
+ text("NextStep", 255),
220
+ textarea("Description", 32000),
221
+ bool("IsClosed", { label: "Closed", readOnly: true }),
222
+ bool("IsWon", { label: "Won", readOnly: true }),
223
+ picklist("ForecastCategory", Object.keys(FORECAST_LABELS), { readOnly: true }),
224
+ picklist("ForecastCategoryName", Object.values(FORECAST_LABELS), { label: "Forecast Category", readOnly: true }),
225
+ OWNER,
226
+ ],
227
+ Task: [
228
+ text("Subject", 255, { nameField: true }),
229
+ picklist("Status", PICKLISTS.TaskStatus, { picklistDefault: "Not Started", required: true }),
230
+ picklist("Priority", PICKLISTS.TaskPriority, { picklistDefault: "Normal", required: true }),
231
+ f("ActivityDate", "date", { label: "Due Date Only" }),
232
+ textarea("Description", 32000, { label: "Comments" }),
233
+ reference("WhoId", ["Contact", "Lead"], "Who", { label: "Name ID" }),
234
+ reference("WhatId", ["Account", "Opportunity"], "What", { label: "Related To ID" }),
235
+ bool("IsClosed", { label: "Closed", readOnly: true }),
236
+ OWNER,
237
+ ],
238
+ User: [
239
+ text("Username", 80, { required: true }),
240
+ f("Email", "email", { length: 128, required: true }),
241
+ text("FirstName", 40),
242
+ text("LastName", 80, { required: true }),
243
+ text("Name", 121, { label: "Full Name", readOnly: true, nameField: true }),
244
+ text("Alias", 8, { required: true }),
245
+ text("Title", 80),
246
+ bool("IsActive", { label: "Active" }),
247
+ reference("ProfileId", ["Profile"], "Profile", { required: true }),
248
+ picklist("UserType", ["Standard"]),
249
+ text("TimeZoneSidKey", 40, { label: "Time Zone", required: true }),
250
+ text("LocaleSidKey", 40, { label: "Locale", required: true }),
251
+ text("LanguageLocaleKey", 40, { label: "Language", required: true }),
252
+ text("EmailEncodingKey", 40, { label: "Email Encoding", required: true }),
253
+ ],
254
+ Profile: [
255
+ text("Name", 255, { required: true, nameField: true }),
256
+ text("UserLicense", 80, { label: "User License" }),
257
+ ],
258
+ };
259
+
260
+ /** Child relationships reachable as SOQL subqueries (`(SELECT … FROM Contacts)`). */
261
+ export const CHILD_RELATIONSHIPS = {
262
+ Account: [
263
+ { relationshipName: "Contacts", childSObject: "Contact", field: "AccountId", cascadeDelete: true },
264
+ { relationshipName: "Opportunities", childSObject: "Opportunity", field: "AccountId", cascadeDelete: true },
265
+ { relationshipName: "Tasks", childSObject: "Task", field: "WhatId", cascadeDelete: true },
266
+ ],
267
+ Contact: [{ relationshipName: "Tasks", childSObject: "Task", field: "WhoId", cascadeDelete: true }],
268
+ Lead: [{ relationshipName: "Tasks", childSObject: "Task", field: "WhoId", cascadeDelete: true }],
269
+ Opportunity: [{ relationshipName: "Tasks", childSObject: "Task", field: "WhatId", cascadeDelete: true }],
270
+ Task: [],
271
+ User: [],
272
+ Profile: [],
273
+ };
274
+
275
+ export function isRecordType(type) {
276
+ return RECORD_TYPES.includes(type);
277
+ }
278
+
279
+ /** A custom-field row (from `custom-fields/<type>`) → catalogue field. Rows not ending in `__c` are ignored. */
280
+ function customField(row) {
281
+ const kinds = { text: "string", textarea: "textarea", number: "double", currency: "currency", percent: "percent", checkbox: "boolean", date: "date", datetime: "datetime", email: "email", url: "url", phone: "phone", picklist: "picklist" };
282
+ const type = kinds[row.type] ?? "string";
283
+ return f(row.name, type, {
284
+ label: row.label,
285
+ length: row.length ?? (type === "string" || type === "textarea" || type === "email" || type === "url" || type === "phone" || type === "picklist" ? 255 : null),
286
+ precision: row.precision ?? null,
287
+ scale: row.scale ?? null,
288
+ required: row.required === true,
289
+ picklist: type === "picklist" ? (row.picklistValues ?? []).map((entry) => entry.value) : null,
290
+ picklistDefault: type === "picklist" ? ((row.picklistValues ?? []).find((entry) => entry.default === true)?.value ?? null) : null,
291
+ defaultValue: row.defaultValue ?? (type === "boolean" ? false : null),
292
+ externalId: row.externalId === true,
293
+ unique: row.unique === true,
294
+ custom: true,
295
+ });
296
+ }
297
+
298
+ /**
299
+ * Full ordered field list of one sObject in Salesforce's rendering order: `Id`, `IsDeleted`, the
300
+ * standard fields, the custom rows, then the audit fields. User and Profile carry no `IsDeleted`,
301
+ * `CreatedById` or `LastModifiedById`.
302
+ */
303
+ export function catalogue(type, customRows) {
304
+ const standard = STANDARD_FIELDS[type] ?? [];
305
+ const custom = (customRows ?? []).filter((row) => typeof row.name === "string" && row.name.endsWith("__c")).map(customField);
306
+ const [id, deleted, createdDate, createdBy, modifiedDate, modifiedBy, modstamp] = SYSTEM_FIELDS;
307
+ return isRecordType(type)
308
+ ? [id, deleted, ...standard, ...custom, createdDate, createdBy, modifiedDate, modifiedBy, modstamp]
309
+ : [id, ...standard, createdDate, modifiedDate, modstamp];
310
+ }
311
+
312
+ /** Names of the audit/system fields a caller may never write. */
313
+ export const SYSTEM_FIELD_NAMES = SYSTEM_FIELDS.map((field) => field.name);
314
+
315
+ export function fieldByName(fields, name) {
316
+ if (typeof name !== "string") return null;
317
+ const lower = name.toLowerCase();
318
+ return fields.find((field) => field.name.toLowerCase() === lower) ?? null;
319
+ }
320
+
321
+ /** Relationship name (`Account`, `Owner`, `Who`) → the lookup field, case-insensitively. */
322
+ export function relationshipByName(fields, name) {
323
+ if (typeof name !== "string") return null;
324
+ const lower = name.toLowerCase();
325
+ return fields.find((field) => field.relationshipName !== null && field.relationshipName.toLowerCase() === lower) ?? null;
326
+ }
327
+
328
+ export function childRelationship(type, name) {
329
+ if (typeof name !== "string") return null;
330
+ const lower = name.toLowerCase();
331
+ return (CHILD_RELATIONSHIPS[type] ?? []).find((entry) => entry.relationshipName.toLowerCase() === lower) ?? null;
332
+ }
333
+
334
+ export function nameFieldOf(type) {
335
+ return type === "Task" ? "Subject" : "Name";
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------------------------
339
+ // Describe rendering
340
+ // ---------------------------------------------------------------------------------------------
341
+
342
+ const SOAP_TYPES = { id: "tns:ID", string: "xsd:string", textarea: "xsd:string", email: "xsd:string", url: "xsd:string", phone: "xsd:string", boolean: "xsd:boolean", int: "xsd:int", double: "xsd:double", currency: "xsd:double", percent: "xsd:double", date: "xsd:date", datetime: "xsd:dateTime", picklist: "xsd:string", reference: "tns:ID" };
343
+
344
+ export function sobjectSummary(type, version, permissions) {
345
+ const [label, labelPlural] = LABELS[type];
346
+ const record = isRecordType(type);
347
+ const base = `/services/data/${version}/sobjects/${type}`;
348
+ return {
349
+ activateable: false,
350
+ associateEntityType: null,
351
+ associateParentEntity: null,
352
+ createable: record && permissions.create === true,
353
+ custom: false,
354
+ customSetting: false,
355
+ deepCloneable: false,
356
+ deletable: record && permissions.delete === true,
357
+ deprecatedAndHidden: false,
358
+ feedEnabled: record,
359
+ hasSubtypes: false,
360
+ isInterface: false,
361
+ isSubtype: false,
362
+ keyPrefix: KEY_PREFIXES[type],
363
+ label,
364
+ labelPlural,
365
+ layoutable: record,
366
+ mergeable: type === "Account" || type === "Contact" || type === "Lead",
367
+ mruEnabled: record,
368
+ name: type,
369
+ queryable: true,
370
+ replicateable: record,
371
+ retrieveable: true,
372
+ searchable: record || type === "User",
373
+ triggerable: record,
374
+ undeletable: record,
375
+ updateable: record && permissions.edit === true,
376
+ urls: { sobject: base, describe: `${base}/describe`, rowTemplate: `${base}/{ID}` },
377
+ };
378
+ }
379
+
380
+ function describeField(field, type) {
381
+ const writable = !field.readOnly && isRecordType(type);
382
+ return {
383
+ name: field.name,
384
+ label: field.label,
385
+ type: field.type,
386
+ length: field.length ?? 0,
387
+ precision: field.precision ?? 0,
388
+ scale: field.scale ?? 0,
389
+ nillable: !field.required && field.name !== "Id",
390
+ createable: writable,
391
+ updateable: writable,
392
+ custom: field.custom,
393
+ defaultedOnCreate: field.name === "OwnerId" || field.name === "Id" || field.picklistDefault !== null || field.type === "boolean" || field.readOnly,
394
+ externalId: field.externalId,
395
+ unique: field.unique,
396
+ idLookup: field.name === "Id" || field.externalId || field.name === "Username",
397
+ nameField: field.nameField,
398
+ filterable: !(field.type === "textarea" && (field.length ?? 0) > 255),
399
+ sortable: !(field.type === "textarea" && (field.length ?? 0) > 255),
400
+ groupable: field.type !== "textarea" && field.type !== "currency" && field.type !== "double" && field.type !== "percent",
401
+ calculated: false,
402
+ picklistValues: (field.picklist ?? []).map((value) => ({ active: true, defaultValue: value === field.picklistDefault, label: value, value })),
403
+ referenceTo: field.referenceTo ?? [],
404
+ relationshipName: field.relationshipName,
405
+ restrictedPicklist: field.type === "picklist",
406
+ soapType: SOAP_TYPES[field.type] ?? "xsd:string",
407
+ };
408
+ }
409
+
410
+ export function describeSObject(type, version, permissions, fields) {
411
+ const summary = sobjectSummary(type, version, permissions);
412
+ const labelPlural = LABELS[type][1];
413
+ return {
414
+ ...summary,
415
+ fields: fields.map((field) => describeField(field, type)),
416
+ childRelationships: (CHILD_RELATIONSHIPS[type] ?? []).map((entry) => ({
417
+ childSObject: entry.childSObject,
418
+ field: entry.field,
419
+ relationshipName: entry.relationshipName,
420
+ cascadeDelete: entry.cascadeDelete,
421
+ restrictedDelete: false,
422
+ })),
423
+ recordTypeInfos: [{ available: true, defaultRecordTypeMapping: true, master: true, name: "Master", recordTypeId: "012000000000000AAA" }],
424
+ supportedScopes: [
425
+ { label: `All ${labelPlural.toLowerCase()}`, name: "everything" },
426
+ { label: `My ${labelPlural.toLowerCase()}`, name: "mine" },
427
+ ],
428
+ };
429
+ }
@@ -0,0 +1,92 @@
1
+ // Parameterized search (`POST …/parameterizedSearch`): free-text prefix matching over the name,
2
+ // e-mail and phone fields of the readable record sObjects, with `*`/`?` wildcards, per-sObject
3
+ // `fields`/`where`/`orderBy`/`limit`, `in` scopes and sharing. No relevance ranking.
4
+
5
+ import { RECORD_TYPES, fieldByName, nameFieldOf } from "./schema.mjs";
6
+ import { renderPaths, requireReadableType, resolvePath, visibleRows } from "./records.mjs";
7
+ import { chargeFilterWork, compileOrder, compilePredicate, parseOrder, parseWhere, tooLargeMessage } from "./soql.mjs";
8
+ import { clip, fieldsOf, permissions, recordError, valueOf } from "./state.mjs";
9
+ import { compileTerms, prepareHaystack } from "./match.mjs";
10
+ import { RESPONSE_BYTE_BUDGET, byteBudget, jsonBytes } from "./bytes.mjs";
11
+
12
+ const SCOPES = {
13
+ NAME: ["Name", "FirstName", "LastName", "Company", "Subject"],
14
+ EMAIL: ["Email"],
15
+ PHONE: ["Phone", "MobilePhone"],
16
+ SIDEBAR: ["Name", "FirstName", "LastName", "Company", "Subject", "Email", "Phone"],
17
+ ALL: ["Name", "FirstName", "LastName", "Company", "Subject", "Email", "Phone", "MobilePhone", "Website", "Title", "Description"],
18
+ };
19
+ const MAX_OVERALL = 2000;
20
+
21
+ /** Split the search string into terms (quoted phrases stay together). */
22
+ function terms(text) {
23
+ const out = [];
24
+ const pattern = /"([^"]*)"|(\S+)/g;
25
+ let match;
26
+ while ((match = pattern.exec(text)) !== null) {
27
+ const term = (match[1] ?? match[2]).trim();
28
+ if (term.length > 0) out.push(term);
29
+ }
30
+ return out;
31
+ }
32
+
33
+
34
+ /** Execute a parameterized search body; returns `{ searchRecords }`. */
35
+ export function parameterizedSearch(session, input, version) {
36
+ const q = typeof input.q === "string" ? input.q.trim() : "";
37
+ if (q.length < 2) throw recordError("MALFORMED_QUERY", "MALFORMED_SEARCH: search term must be at least two characters");
38
+ if (q.length > 200) throw recordError("MALFORMED_QUERY", "MALFORMED_SEARCH: search term must be at most 200 characters");
39
+ const scope = typeof input.in === "string" ? input.in.toUpperCase() : "ALL";
40
+ const scopeFields = Object.hasOwn(SCOPES, scope) ? SCOPES[scope] : undefined;
41
+ if (scopeFields === undefined) throw recordError("MALFORMED_QUERY", `MALFORMED_SEARCH: unknown search scope '${clip(input.in)}'`);
42
+ const parts = terms(q);
43
+ if (parts.length === 0) throw recordError("MALFORMED_QUERY", "MALFORMED_SEARCH: search term must contain at least one word");
44
+ // Every term runs in one linear pass over each record's folded haystack.
45
+ const matcher = compileTerms(parts);
46
+ const overallLimit = Number.isInteger(input.overallLimit) ? Math.min(input.overallLimit, MAX_OVERALL) : MAX_OVERALL;
47
+ const defaultLimit = Number.isInteger(input.defaultLimit) ? input.defaultLimit : null;
48
+ const globalFields = Array.isArray(input.fields) ? input.fields : null;
49
+
50
+ const requested = Array.isArray(input.sobjects) && input.sobjects.length > 0
51
+ ? input.sobjects
52
+ : RECORD_TYPES.filter((type) => permissions(session, type).read).map((name) => ({ name }));
53
+
54
+ const searchRecords = [];
55
+ // Search has no paging: results that would encode past the response budget fail LIMIT_EXCEEDED.
56
+ const budget = byteBudget(RESPONSE_BYTE_BUDGET, jsonBytes({ searchRecords: [] }));
57
+ const seenTypes = new Set();
58
+ for (const entry of requested) {
59
+ if (typeof entry !== "object" || entry === null || typeof entry.name !== "string") throw recordError("JSON_PARSER_ERROR", "sobjects entries must carry a name");
60
+ const type = requireReadableType(session, entry.name);
61
+ // Each sObject may appear once, so one request scans each type at most once.
62
+ if (seenTypes.has(type)) throw recordError("MALFORMED_QUERY", `MALFORMED_SEARCH: duplicate sObject type '${clip(type)}' in sobjects`);
63
+ seenTypes.add(type);
64
+ const fields = fieldsOf(session, type);
65
+ const searchable = scopeFields.map((name) => fieldByName(fields, name)).filter((field) => field !== null);
66
+ const wanted = Array.isArray(entry.fields) ? entry.fields : (globalFields ?? ["Id", nameFieldOf(type)]);
67
+ const paths = wanted.map((path) => resolvePath(session, type, String(path), "entity"));
68
+ const where = typeof entry.where === "string" && entry.where.trim().length > 0 ? parseWhere(entry.where) : null;
69
+ const predicate = where !== null ? compilePredicate(session, type, where) : null;
70
+ const order = typeof entry.orderBy === "string" && entry.orderBy.trim().length > 0 ? compileOrder(session, type, parseOrder(entry.orderBy)) : null;
71
+ if (entry.fields !== undefined && !Array.isArray(entry.fields)) throw recordError("JSON_PARSER_ERROR", "sobjects[].fields must be an array of field names");
72
+ if (entry.limit !== undefined && !Number.isInteger(entry.limit)) throw recordError("JSON_PARSER_ERROR", "sobjects[].limit must be an integer");
73
+ const limit = Number.isInteger(entry.limit) ? entry.limit : defaultLimit;
74
+ let rows = visibleRows(session, type).filter((row) => {
75
+ const haystack = searchable.map((field) => valueOf(type, row, field.name)).filter((value) => value !== null).map(String).join(" \n ");
76
+ return matcher(prepareHaystack(haystack));
77
+ });
78
+ if (predicate !== null) {
79
+ chargeFilterWork(session, where, rows.length);
80
+ rows = rows.filter(predicate);
81
+ }
82
+ if (order !== null) rows = order(rows);
83
+ if (limit !== null) rows = rows.slice(0, Math.max(0, limit));
84
+ for (const row of rows) {
85
+ if (searchRecords.length >= overallLimit) break;
86
+ const record = renderPaths(session, type, row, version, paths);
87
+ if (!budget.admit(record)) throw recordError("LIMIT_EXCEEDED", tooLargeMessage(RESPONSE_BYTE_BUDGET));
88
+ searchRecords.push(record);
89
+ }
90
+ }
91
+ return { searchRecords };
92
+ }